Skip to content

Commit 083d38e

Browse files
Suspend idle compiled machine workers
1 parent ced53ca commit 083d38e

5 files changed

Lines changed: 496 additions & 18 deletions

File tree

.changeset/calm-workers-rest.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Suspend compiled statechart workers while their mailboxes are idle and start an
6+
on-demand drain when an event arrives. This reduces retained heap for idle
7+
machines and invoked families without changing event ordering, terminal
8+
arbitration, or the public machine API.

perf/runtime/counter.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ const rawProcessLogic = {
232232
const rawCompiledProcessLogic = {
233233
[machineRuntime.compiledProcess]: true,
234234
initial: () => Effect.succeed(0),
235-
run: () => Effect.never
235+
run: () => Effect.never,
236+
drain: () => Effect.succeed(Option.none())
236237
}
237238

238239
const startRawProcesses = (count) =>

src/internal/machineProcess.ts

Lines changed: 306 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ const makeProcessLogic: <
119119
entry: ProcessEntry<States, Input>
120120
) => {
121121
const hasInvokes = hasInvokeCapability(machine)
122+
// A process logic value may be reused for multiple starts. Key retained
123+
// invoke state by the stable process address so each running instance owns
124+
// its own table even when an invoke wrapper decorates the context object.
125+
const invokeSessionsByContext = new WeakMap<object, {
126+
readonly sessions: Map<string, InvokeSession>
127+
initialized: boolean
128+
}>()
122129
return ({
123130
[internalRuntime.childlessProcess]: hasInvokes ? undefined : true,
124131
[internalRuntime.compiledProcess]: true,
@@ -143,6 +150,300 @@ const makeProcessLogic: <
143150
}),
144151
scope
145152
),
153+
drain: (context: internalRuntime.ProcessContext<Machine.Snapshot<States>, Machine.EventOf<Events>>) =>
154+
internalRuntime.provideMachineRuntime(
155+
Effect.gen(function*() {
156+
const { mailbox, state, setState } = context
157+
let current = yield* state
158+
if (internalPlanner.isFinalState(machine, current)) {
159+
return Option.some(
160+
yield* internalPlanner.getFinalOutputEffect<States, Events, Output>(
161+
machine,
162+
current,
163+
internalPlanner.InitialEvent
164+
)
165+
)
166+
}
167+
168+
let configuration: Model.ActiveConfiguration | undefined
169+
let liveRuntime: Runtime<Machine.EventOf<Events>, Machine.EmitOf<Emits>> | undefined
170+
171+
let stopAllInvokes: Effect.Effect<void> | undefined
172+
let startInvokes:
173+
| ((
174+
configuration: Model.ActiveConfiguration,
175+
paths: ReadonlyArray<string>,
176+
event: Machine.LifecycleEvent<Events>
177+
) => Effect.Effect<void, E | MachineSchemaDecodeError, R>)
178+
| undefined
179+
let stopInvokes: ((paths: ReadonlyArray<string>) => Effect.Effect<void>) | undefined
180+
181+
if (hasInvokes) {
182+
let session = invokeSessionsByContext.get(context.self)
183+
if (session === undefined) {
184+
session = { sessions: new Map(), initialized: false }
185+
invokeSessionsByContext.set(context.self, session)
186+
}
187+
const invokeSessions = session.sessions
188+
const makeInvokeSessionKey = (path: string, id: string): string => `${path.length}:${path}${id}`
189+
const makeInvokeChildId = (path: string, id: string): string =>
190+
`Machine.invoke:${makeInvokeSessionKey(path, id)}`
191+
const isCurrentInvoke = (key: string, token: symbol): Effect.Effect<boolean> =>
192+
Effect.sync(() => invokeSessions.get(key)?.token === token)
193+
const stopInvokeSession = (session: InvokeSession): Effect.Effect<void> =>
194+
context.stopChild(session.childId)
195+
const removeInvoke = (
196+
key: string,
197+
token: symbol | undefined
198+
): Effect.Effect<void> =>
199+
Effect.sync(() => {
200+
const current = invokeSessions.get(key)
201+
if (current === undefined || (token !== undefined && current.token !== token)) {
202+
return undefined
203+
}
204+
invokeSessions.delete(key)
205+
return current
206+
}).pipe(
207+
Effect.flatMap((session) =>
208+
session === undefined
209+
? Effect.void
210+
: stopInvokeSession(session)
211+
)
212+
)
213+
const stopInvoke = (key: string): Effect.Effect<void> => removeInvoke(key, undefined)
214+
stopAllInvokes = Effect.sync(() => {
215+
const sessions = Array.from(invokeSessions.values())
216+
invokeSessions.clear()
217+
return sessions
218+
}).pipe(
219+
Effect.flatMap((sessions) =>
220+
Effect.all(
221+
sessions.map((session) => stopInvokeSession(session)),
222+
{ discard: true, concurrency: "unbounded" }
223+
)
224+
)
225+
)
226+
const handleInvokeOutcome = (
227+
config: AnyInvokeConfig,
228+
key: string,
229+
token: symbol,
230+
outcome: internalRuntime.RuntimeOutcome<any, any, any>
231+
): Effect.Effect<void> => {
232+
if (outcome._tag === "Stopped") {
233+
return Effect.void
234+
}
235+
return isCurrentInvoke(key, token).pipe(
236+
Effect.flatMap((isCurrent) => {
237+
if (!isCurrent) {
238+
return Effect.void
239+
}
240+
if (outcome._tag === "Done") {
241+
const mappedEvent = config.onDone === undefined
242+
? outcome.output
243+
: config.onDone({ id: config.id, output: outcome.output })
244+
return mappedEvent === undefined
245+
? Effect.void
246+
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
247+
Effect.catchTag("StoppedError", () => Effect.void)
248+
)
249+
}
250+
return context.failCause(outcome.cause)
251+
})
252+
)
253+
}
254+
const handleInvokeSnapshot = (
255+
config: AnyInvokeConfig,
256+
key: string,
257+
token: symbol,
258+
snapshot: Extract<internalRuntime.RuntimeSnapshot<any, any, any>, { readonly status: "active" }>
259+
): Effect.Effect<void> =>
260+
isCurrentInvoke(key, token).pipe(
261+
Effect.flatMap((isCurrent) => {
262+
if (!isCurrent || config.snapshot === undefined) {
263+
return Effect.void
264+
}
265+
const mappedEvent = config.snapshot({ id: config.id, snapshot })
266+
return mappedEvent === undefined
267+
? Effect.void
268+
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
269+
Effect.catchTag("StoppedError", () => Effect.void)
270+
)
271+
})
272+
)
273+
const startInvoke = Effect.fnUntraced(function*<StateId extends Machine.StateIdentifier<States>>(
274+
path: StateId,
275+
config: AnyInvokeConfig
276+
) {
277+
const token = Symbol()
278+
const invokeId = String(config.id)
279+
const key = makeInvokeSessionKey(path, invokeId)
280+
const childId = config.address === undefined ? makeInvokeChildId(path, invokeId) : String(config.address)
281+
const reserved = yield* Effect.sync(() => {
282+
if (invokeSessions.has(key)) {
283+
return false
284+
}
285+
invokeSessions.set(key, { token, childId, path })
286+
return true
287+
})
288+
if (!reserved) {
289+
return yield* Effect.fail(new ChildAlreadyExistsError({ id: invokeId }))
290+
}
291+
const logic = config.src()
292+
const processLogic = logic as internalRuntime.ProcessLogic<any, any, any, any, any, any>
293+
const sendParent = (event: unknown): Effect.Effect<void, StoppedError> =>
294+
isCurrentInvoke(key, token).pipe(
295+
Effect.flatMap((isCurrent) =>
296+
isCurrent ? context.self.send(event as Machine.EventOf<Events>) : Effect.void
297+
)
298+
)
299+
yield* context.spawn(
300+
{
301+
...(processLogic[internalRuntime.childlessProcess] === true
302+
? { [internalRuntime.childlessProcess]: true as const }
303+
: undefined),
304+
...(processLogic[internalRuntime.compiledProcess] === true
305+
? { [internalRuntime.compiledProcess]: true as const }
306+
: undefined),
307+
initial: (childScope) => logic.initial({ ...childScope, sendParent }),
308+
run: (childContext) => logic.run({ ...childContext, sendParent }),
309+
...(processLogic.drain === undefined ? undefined : {
310+
drain: (childContext: internalRuntime.ProcessContext<any, any>) =>
311+
processLogic.drain!({ ...childContext, sendParent })
312+
})
313+
},
314+
{
315+
id: childId,
316+
...(config.descriptor === undefined ? undefined : { descriptor: config.descriptor }),
317+
onOutcome: (outcome) => handleInvokeOutcome(config, key, token, outcome),
318+
...(config.snapshot === undefined ? undefined : {
319+
[internalRuntime.activeSnapshotObserver]: (snapshot) =>
320+
handleInvokeSnapshot(config, key, token, snapshot)
321+
})
322+
}
323+
).pipe(
324+
Effect.onExit((exit) =>
325+
Exit.isFailure(exit)
326+
? removeInvoke(key, token)
327+
: Effect.void
328+
)
329+
)
330+
})
331+
startInvokes = Effect.fnUntraced(function*(
332+
configuration: Model.ActiveConfiguration,
333+
paths: ReadonlyArray<string>,
334+
event: Machine.LifecycleEvent<Events>
335+
) {
336+
yield* Effect.all(
337+
internalPlanner.sortEntryPaths(machine, paths)
338+
.filter((path) => configuration.active.has(path))
339+
.flatMap((path) =>
340+
getInvokes(Model.getStateConfigByPath(machine, path), {
341+
state: Model.getActiveValue(configuration, path),
342+
parent: Model.getParentValue(machine, configuration, path),
343+
parents: Model.getParentValues(machine, configuration, path),
344+
event
345+
}).map((config) =>
346+
startInvoke(
347+
path as Machine.StateIdentifier<States>,
348+
config
349+
) as Effect.Effect<void, E | MachineSchemaDecodeError, R>
350+
)
351+
),
352+
{ discard: true }
353+
)
354+
})
355+
stopInvokes = (paths) =>
356+
Effect.sync(() =>
357+
internalPlanner.sortExitPaths(machine, paths).flatMap((path) =>
358+
Array.from(invokeSessions.entries())
359+
.filter(([, session]) => session.path === path)
360+
.map(([key]) => key)
361+
)
362+
).pipe(
363+
Effect.flatMap((keys) =>
364+
Effect.all(
365+
keys.map(stopInvoke),
366+
{ discard: true, concurrency: "unbounded" }
367+
)
368+
)
369+
)
370+
371+
configuration = Model.normalizeConfigurationSync(machine, current)
372+
if (!session.initialized) {
373+
yield* startInvokes(
374+
configuration,
375+
Model.getInitialEntryPaths(machine, configuration),
376+
internalPlanner.InitialEvent
377+
)
378+
session.initialized = true
379+
}
380+
}
381+
382+
while (true) {
383+
const pending = yield* Queue.poll(mailbox)
384+
if (Option.isNone(pending)) {
385+
return Option.none<Output>()
386+
}
387+
const event = pending.value
388+
let planned
389+
try {
390+
planned = internalPlanner.planConfiguration(
391+
machine,
392+
configuration ?? Model.normalizeConfigurationSync(machine, current),
393+
event
394+
)
395+
} catch (error) {
396+
if (error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError) {
397+
return yield* error
398+
}
399+
throw error
400+
}
401+
configuration = planned.next
402+
403+
if (planned.microsteps.length > 0) {
404+
let changed = false
405+
let exitPaths: ReadonlyArray<string> = []
406+
let entryEvents: Map<string, Machine.LifecycleEvent<Events>> | undefined
407+
if (hasInvokes) {
408+
changed = planned.microsteps.some((step) => step.changed)
409+
exitPaths = planned.microsteps.flatMap((step) => step.exitPaths)
410+
entryEvents = new Map()
411+
for (const step of planned.microsteps) {
412+
if (step.changed) {
413+
for (const path of step.entryPaths) {
414+
entryEvents.set(path, step.event as Machine.LifecycleEvent<Events>)
415+
}
416+
}
417+
}
418+
}
419+
const next = Model.snapshotFromConfiguration<States>(machine, planned.next)
420+
yield* internalPlanner.runCommands(planned.commands, context)
421+
if (changed) {
422+
yield* stopInvokes!(exitPaths)
423+
}
424+
yield* setState(next)
425+
current = next
426+
if (planned.emittedEvents.length > 0) {
427+
yield* internalPlanner.runEmittedEvents(
428+
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
429+
liveRuntime ??= internalPlanner.makeLiveRuntime(machine, context)
430+
)
431+
}
432+
if (planned.done) {
433+
if (stopAllInvokes !== undefined) {
434+
yield* stopAllInvokes
435+
}
436+
return Option.some(planned.output as Output)
437+
} else if (changed) {
438+
for (const [path, entryEvent] of entryEvents!) {
439+
yield* startInvokes!(planned.next, [path], entryEvent)
440+
}
441+
}
442+
}
443+
}
444+
}),
445+
context
446+
),
146447
run: (context) =>
147448
internalRuntime.provideMachineRuntime(
148449
Effect.gen(function*() {
@@ -344,7 +645,11 @@ const makeProcessLogic: <
344645
? { [internalRuntime.compiledProcess]: true as const }
345646
: undefined),
346647
initial: (childScope) => logic.initial({ ...childScope, sendParent }),
347-
run: (childContext) => logic.run({ ...childContext, sendParent })
648+
run: (childContext) => logic.run({ ...childContext, sendParent }),
649+
...(processLogic.drain === undefined ? undefined : {
650+
drain: (childContext: internalRuntime.ProcessContext<any, any>) =>
651+
processLogic.drain!({ ...childContext, sendParent })
652+
})
348653
},
349654
{
350655
id: childId,

0 commit comments

Comments
 (0)