Actyx / machines

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[API] `usingMachineRunner` to borrow a machine (as opposed to owning)

Kelerchian opened this issue · comments

Problem

A MachineRunner instance contains a living Actyx subscription before it is destroyed. Currently, the programmer has to make sure that either for-await loop is used or destroy to make sure an orphan subscription is not running. There are a lot of cases where an instance is used temporarily without a 'for-await' loop; it is created, extracted, and then destroyed. Such cases have a risk---a missed destroy call.

Missing a destroy call is caused by an early return or a throw. But its root cause is the lifetime ownership of MachineRunner. For this issue, I am going to borrow some terms:

  • Lifetime of a MachineRunner: The period when a MachineRunner lives and its destruction
  • Ownership of a MachineRunner; The responsibility of a function or an object over a MachineRunner's lifetime --- the need to explicitly call 'destroy' after an instance is not used anymore

For example, an early return creates an orphaned machine.

const extractSomeData = async (): Promise<SomePayload | null> => {
  const machine = createMachineRunner(actyx, tags, Initial, payload); // a machine is created
  const state = (await machine?.peek())?.value;
  // An early return. Here, the program forgets to call `machine.destroy()`, thus creating a living orphan machine
  if (!state) {
    return null;
  }
  const payload = state.payload;
  machine.destroy();
  return payload;
}

A manual review is required to see the mistake in the piece of code above. The proper fix is:

if (!state) {
  machine.destroy();
  return null;
}

This is only one example of a missable destroy call. Another class of missable is less visible to a code review, which is a thrown Exception.

Solution

The proposed API aims to take over the ownership of a MachineRunner and instead allow the programmer to borrow an instance.

The API set will expose two things:

  • a function usingMachineRunner which accepts a similar set of arguments to createMachineRunner.
  • a type MachineRunnerUseFn<Factory, ReturnType, Payload = unknown> which enables the user to write a 'dependency-injection' style.

With this API, the problematic example above can be rewritten as:

const extractSomeData = () => usingMachineRunner(actyx, tags, Initial, Payload, async (machine) => {
  const state = (await machine?.peek())?.value;
  if (!state) return null;
  const payload = state.payload;
  return payload;
});

usingMachineRunner does what createMachineRunner does and then execute the function passed by the user. With this pattern, the library now can destroy the machine after the function execution is done or if it throws, therefore eliminating the need for an explicit call to the destroy method.

for-await loop can also be used in the same way.

usingMachineRunner(actyx, tags, Initial, Payload, async (machine) => {
  for await (const state of machine) {
    ...
  }
});

Dependency injection can be achieved by the provided type. For example, a class wants to allow parametrization to only tags and the function body.

import { Initial } from "protocol/someRole";
class SomeClass {
  actyx: Actyx,
  
  // Dependency injection happens here
  useMachine<T>(tags: Tags, useFn: MachineRunnerUseFn<Initial, T>){
    return usingMachineRunner(this.actyx, tags, Initial, useFn);
  }
}

// subsequently the class can be used this way
const someClass = new SomeClass(...);
const result = await someClass.useMachine(createSomeTags(), (machine) => {
  for await (const state of machine) {
    ...
  }
});

Yes, this is a good idea — in particular in light of seeing machine leaks in production code.