[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 tocreateMachineRunner
. - 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.