Skip to content

Commit 381cfe3

Browse files
Compact invoked child supervision (#44)
* Compact invoked child supervision * Skip stopped invoke outcome lookups
1 parent 73c1e6d commit 381cfe3

6 files changed

Lines changed: 212 additions & 80 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Reduce invoked-child memory and lifecycle overhead by delivering terminal outcomes directly through process supervision and allocating a watcher fiber only for invokes that map active child snapshots.

perf/runtime/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ The command reports:
1717
- parent-with-child start-and-stop throughput;
1818
- heap and resident-memory growth at 100, 500, and 1,000 live units, including
1919
a raw managed process, an idle statechart, two independent statecharts, a
20-
parent with one child, and that relationship with child observation active;
20+
parent with one child, that relationship with child-registry observation
21+
active, and an invoked child whose active snapshots are observed;
2122
- lower-bound memory profiles for Effect itself: a suspended fiber, a queue
2223
with a waiting fiber, and a minimal mailbox/state/completion actor shell.
2324

@@ -56,10 +57,11 @@ same runtime guarantees.
5657
The fitted heap slope is the primary idle-capacity metric. Compare adjacent
5758
profiles to attribute retained memory: raw process to idle statechart isolates
5859
statechart machinery, two independent machines to parent-with-child isolates
59-
relationship bookkeeping, and unobserved to observed parent-child isolates
60-
observation. The Effect profiles are primitive lower bounds, not feature-equivalent
61-
competitors. Resident memory is reported as a raw diagnostic because V8 and the
62-
operating-system allocator can reuse already committed pages. The
60+
relationship bookkeeping, while the two observed parent-child profiles isolate
61+
registry and invoked-snapshot observation. The Effect profiles are primitive
62+
lower bounds, not feature-equivalent competitors. Resident memory is reported
63+
as a raw diagnostic because V8 and the operating-system allocator can reuse
64+
already committed pages. The
6365
capacity-per-GiB value is a linear estimate that excludes shared process
6466
overhead; it is not a run-until-OOM limit.
6567

perf/runtime/counter.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ const counterParentMachine = Machine.make({
7171
}
7272
})
7373

74+
const counterSnapshotParentMachine = Machine.make({
75+
id: "RuntimeBenchmarkSnapshotCounterParent",
76+
states: ParentStates.states,
77+
events: [],
78+
initial: () => ParentStates.initial.Active(ParentState.cases.Active.make({}))
79+
}).handle({
80+
Active: {
81+
invoke: Machine.invokeMachine({
82+
child: CounterChild,
83+
snapshot: () => undefined
84+
})
85+
}
86+
})
87+
7488
export const incrementEvent = CounterEvent.cases.Increment.make({})
7589
const finishEvent = CounterEvent.cases.Finish.make({})
7690

@@ -254,6 +268,20 @@ const startObservedChildCounters = (count) =>
254268
)
255269
)
256270

271+
const startSnapshotObservedChildCounters = (count) =>
272+
Effect.runPromise(
273+
Effect.forEach(
274+
Array.from({ length: count }),
275+
() =>
276+
Effect.gen(function*() {
277+
const parent = yield* Machine.start(counterSnapshotParentMachine)
278+
yield* waitForCounterChild(parent)
279+
return parent
280+
}),
281+
{ concurrency: 1 }
282+
)
283+
)
284+
257285
const stopObservedChildCounters = (units) =>
258286
Effect.runPromise(
259287
Effect.forEach(
@@ -327,6 +355,11 @@ export const effectMachineAdapter = {
327355
label: "Parent with observed child registry",
328356
start: startObservedChildCounters,
329357
stop: stopObservedChildCounters
358+
},
359+
"snapshot-observed-parent-with-child": {
360+
label: "Parent with invoked child snapshot watcher",
361+
start: startSnapshotObservedChildCounters,
362+
stop: stopChildCounters
330363
}
331364
}
332365
}

src/internal/machineProcess.ts

Lines changed: 84 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66

77
import * as Effect from "effect/Effect"
88
import * as Exit from "effect/Exit"
9+
import * as Fiber from "effect/Fiber"
910
import * as HashMap from "effect/HashMap"
1011
import * as Option from "effect/Option"
1112
import * as Queue from "effect/Queue"
1213
import * as Ref from "effect/Ref"
1314
import type * as Schema from "effect/Schema"
14-
import * as Scope from "effect/Scope"
1515
import * as Stream from "effect/Stream"
1616
import type { ActionError, ExecutionServices, Machine, Runtime } from "../Machine.js"
1717
import { ChildAlreadyExistsError, InfiniteTransitionError, MachineSchemaDecodeError } from "./machineErrors.js"
@@ -34,7 +34,7 @@ type AnyInvokeConfig = Machine.InvokeConfig<any, any, any, any, any, any, any, a
3434

3535
interface InvokeSession {
3636
readonly token: symbol
37-
readonly scope: Scope.Closeable
37+
readonly watcher: Fiber.Fiber<void> | undefined
3838
readonly childId: string
3939
readonly path: string
4040
}
@@ -240,10 +240,17 @@ const makeProcessLogic: <
240240
return Option.isSome(current) && current.value.token === token
241241
})
242242
)
243+
const stopInvokeSession = (session: InvokeSession): Effect.Effect<void> =>
244+
Effect.all(
245+
[
246+
...(session.watcher === undefined ? [] : [Fiber.interrupt(session.watcher)]),
247+
context.stopChild(session.childId)
248+
],
249+
{ discard: true, concurrency: "unbounded" }
250+
)
243251
const removeInvoke = (
244252
key: string,
245-
token: symbol | undefined,
246-
exit: Exit.Exit<unknown, unknown>
253+
token: symbol | undefined
247254
): Effect.Effect<void> =>
248255
Ref.modify(invokeSessions, (sessions) => {
249256
const current = HashMap.get(sessions, key)
@@ -254,80 +261,91 @@ const makeProcessLogic: <
254261
Effect.flatMap((session) =>
255262
session === undefined
256263
? Effect.void
257-
: Scope.close(session.scope, exit).pipe(
258-
Effect.andThen(context.stopChild(session.childId))
259-
)
264+
: stopInvokeSession(session)
260265
)
261266
)
262-
const stopInvoke = (key: string, exit: Exit.Exit<unknown, unknown>): Effect.Effect<void> =>
263-
removeInvoke(key, undefined, exit)
264-
const stopAllInvokes = (exit: Exit.Exit<unknown, unknown>): Effect.Effect<void> =>
265-
Ref.modify(invokeSessions, (sessions) => [HashMap.toEntries(sessions), HashMap.empty()] as const).pipe(
267+
const stopInvoke = (key: string): Effect.Effect<void> => removeInvoke(key, undefined)
268+
const stopAllInvokes: Effect.Effect<void> = Ref.modify(invokeSessions, (sessions) =>
269+
[HashMap.toEntries(sessions), HashMap.empty()] as const).pipe(
266270
Effect.flatMap((sessions) =>
267271
Effect.all(
268272
sessions.map(([, session]) =>
269-
Scope.close(session.scope, exit).pipe(
270-
Effect.andThen(context.stopChild(session.childId))
271-
)
273+
stopInvokeSession(session)
272274
),
273275
{ discard: true, concurrency: "unbounded" }
274276
)
275277
)
276278
)
277-
const startInvokeWatchers = Effect.fnUntraced(function*(
279+
const handleInvokeOutcome = (
278280
config: AnyInvokeConfig,
279-
child: internalRuntime.MachineRef<any, any, any, any>,
280281
key: string,
281282
token: symbol,
282-
scope: Scope.Closeable
283+
outcome: internalRuntime.RuntimeOutcome<any, any, any>
284+
): Effect.Effect<void> => {
285+
if (outcome._tag === "Stopped") {
286+
return Effect.void
287+
}
288+
return isCurrentInvoke(key, token).pipe(
289+
Effect.flatMap((isCurrent) => {
290+
if (!isCurrent) {
291+
return Effect.void
292+
}
293+
if (outcome._tag === "Done") {
294+
const mappedEvent = config.onDone === undefined
295+
? outcome.output
296+
: config.onDone({ id: config.id, output: outcome.output })
297+
return mappedEvent === undefined
298+
? Effect.void
299+
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
300+
Effect.catchTag("StoppedError", () => Effect.void)
301+
)
302+
}
303+
return context.failCause(outcome.cause)
304+
})
305+
)
306+
}
307+
const startInvokeSnapshotWatcher = Effect.fnUntraced(function*(
308+
config: AnyInvokeConfig,
309+
child: internalRuntime.MachineRef<any, any, any, any>,
310+
key: string,
311+
token: symbol
283312
) {
284-
if (config.snapshot !== undefined) {
285-
const mapSnapshot = config.snapshot
286-
yield* child.changes.pipe(
287-
Stream.filter((snapshot) => snapshot.status === "active"),
288-
Stream.runForEach((snapshot) =>
289-
isCurrentInvoke(key, token).pipe(
290-
Effect.flatMap((isCurrent) => {
291-
if (!isCurrent) {
292-
return Effect.void
293-
}
294-
const mappedEvent = mapSnapshot({ id: config.id, snapshot })
295-
return mappedEvent === undefined
296-
? Effect.void
297-
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
298-
Effect.catchTag("StoppedError", () => Effect.void)
299-
)
300-
})
301-
)
302-
),
303-
Effect.forkIn(scope),
304-
Effect.asVoid
305-
)
313+
if (config.snapshot === undefined) {
314+
return
306315
}
307-
yield* internalRuntime.watch(child).pipe(
308-
Stream.runForEach((outcome) =>
316+
const mapSnapshot = config.snapshot
317+
const watcher = yield* child.changes.pipe(
318+
Stream.filter((snapshot) => snapshot.status === "active"),
319+
Stream.runForEach((snapshot) =>
309320
isCurrentInvoke(key, token).pipe(
310321
Effect.flatMap((isCurrent) => {
311-
if (!isCurrent || outcome._tag === "Stopped") {
322+
if (!isCurrent) {
312323
return Effect.void
313324
}
314-
if (outcome._tag === "Done") {
315-
const mappedEvent = config.onDone === undefined
316-
? outcome.output
317-
: config.onDone({ id: config.id, output: outcome.output })
318-
return mappedEvent === undefined
319-
? Effect.void
320-
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
321-
Effect.catchTag("StoppedError", () => Effect.void)
322-
)
323-
}
324-
return context.failCause(outcome.cause)
325+
const mappedEvent = mapSnapshot({ id: config.id, snapshot })
326+
return mappedEvent === undefined
327+
? Effect.void
328+
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
329+
Effect.catchTag("StoppedError", () => Effect.void)
330+
)
325331
})
326332
)
327333
),
328-
Effect.forkIn(scope),
329-
Effect.asVoid
334+
Effect.forkChild
330335
)
336+
const installed = yield* Ref.modify(invokeSessions, (sessions) => {
337+
const current = HashMap.get(sessions, key)
338+
if (Option.isNone(current) || current.value.token !== token) {
339+
return [false, sessions] as const
340+
}
341+
return [
342+
true,
343+
HashMap.set(sessions, key, { ...current.value, watcher })
344+
] as const
345+
})
346+
if (!installed) {
347+
yield* Fiber.interrupt(watcher)
348+
}
331349
})
332350
const startInvoke = Effect.fnUntraced(function*<StateId extends Machine.StateIdentifier<States>>(
333351
path: StateId,
@@ -337,13 +355,11 @@ const makeProcessLogic: <
337355
const invokeId = String(config.id)
338356
const key = makeInvokeSessionKey(path, invokeId)
339357
const childId = config.address === undefined ? makeInvokeChildId(path, invokeId) : String(config.address)
340-
const scope = yield* Scope.make("parallel")
341358
const reserved = yield* Ref.modify(invokeSessions, (sessions) =>
342359
HashMap.has(sessions, key)
343360
? [false, sessions] as const
344-
: [true, HashMap.set(sessions, key, { token, scope, childId, path })] as const)
361+
: [true, HashMap.set(sessions, key, { token, watcher: undefined, childId, path })] as const)
345362
if (!reserved) {
346-
yield* Scope.close(scope, Exit.void)
347363
return yield* Effect.fail(new ChildAlreadyExistsError({ id: invokeId }))
348364
}
349365
const logic = config.src()
@@ -358,24 +374,19 @@ const makeProcessLogic: <
358374
initial: (childScope) => logic.initial({ ...childScope, sendParent }),
359375
run: (childContext) => logic.run({ ...childContext, sendParent })
360376
},
361-
config.descriptor === undefined
362-
? { id: childId }
363-
: { id: childId, descriptor: config.descriptor }
377+
{
378+
id: childId,
379+
...(config.descriptor === undefined ? undefined : { descriptor: config.descriptor }),
380+
onOutcome: (outcome) => handleInvokeOutcome(config, key, token, outcome)
381+
}
364382
).pipe(
365383
Effect.onExit((exit) =>
366384
Exit.isFailure(exit)
367-
? Ref.update(invokeSessions, (sessions) => {
368-
const current = HashMap.get(sessions, key)
369-
return Option.isSome(current) && current.value.token === token
370-
? HashMap.remove(sessions, key)
371-
: sessions
372-
}).pipe(
373-
Effect.andThen(Scope.close(scope, Exit.failCause(exit.cause)))
374-
)
385+
? removeInvoke(key, token)
375386
: Effect.void
376387
)
377388
)
378-
yield* startInvokeWatchers(config, child, key, token, scope)
389+
yield* startInvokeSnapshotWatcher(config, child, key, token)
379390
})
380391
const startInvokes: (
381392
configuration: Model.ActiveConfiguration,
@@ -412,7 +423,7 @@ const makeProcessLogic: <
412423
internalPlanner.sortExitPaths(machine, paths).flatMap((path) =>
413424
HashMap.toEntries(sessions)
414425
.filter(([, session]) => session.path === path)
415-
.map(([key]) => stopInvoke(key, Exit.void))
426+
.map(([key]) => stopInvoke(key))
416427
),
417428
{ discard: true, concurrency: "unbounded" }
418429
)
@@ -481,7 +492,7 @@ const makeProcessLogic: <
481492

482493
if (planned.done) {
483494
terminal = { output: planned.output as Output }
484-
yield* stopAllInvokes(Exit.succeed(planned.output))
495+
yield* stopAllInvokes
485496
} else {
486497
if (changed) {
487498
for (const [path, entryEvent] of entryEvents) {
@@ -509,7 +520,7 @@ const makeProcessLogic: <
509520
}
510521
return terminal.output
511522
}).pipe(
512-
Effect.onExit((exit) => stopAllInvokes(exit))
523+
Effect.onExit(() => stopAllInvokes)
513524
)
514525
}),
515526
context

0 commit comments

Comments
 (0)