Skip to content

Commit cf8ca21

Browse files
Compact invoked child supervision
1 parent 73c1e6d commit cf8ca21

6 files changed

Lines changed: 208 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: 80 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,87 @@ 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+
isCurrentInvoke(key, token).pipe(
286+
Effect.flatMap((isCurrent) => {
287+
if (!isCurrent || outcome._tag === "Stopped") {
288+
return Effect.void
289+
}
290+
if (outcome._tag === "Done") {
291+
const mappedEvent = config.onDone === undefined
292+
? outcome.output
293+
: config.onDone({ id: config.id, output: outcome.output })
294+
return mappedEvent === undefined
295+
? Effect.void
296+
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
297+
Effect.catchTag("StoppedError", () => Effect.void)
298+
)
299+
}
300+
return context.failCause(outcome.cause)
301+
})
302+
)
303+
const startInvokeSnapshotWatcher = Effect.fnUntraced(function*(
304+
config: AnyInvokeConfig,
305+
child: internalRuntime.MachineRef<any, any, any, any>,
306+
key: string,
307+
token: symbol
283308
) {
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-
)
309+
if (config.snapshot === undefined) {
310+
return
306311
}
307-
yield* internalRuntime.watch(child).pipe(
308-
Stream.runForEach((outcome) =>
312+
const mapSnapshot = config.snapshot
313+
const watcher = yield* child.changes.pipe(
314+
Stream.filter((snapshot) => snapshot.status === "active"),
315+
Stream.runForEach((snapshot) =>
309316
isCurrentInvoke(key, token).pipe(
310317
Effect.flatMap((isCurrent) => {
311-
if (!isCurrent || outcome._tag === "Stopped") {
318+
if (!isCurrent) {
312319
return Effect.void
313320
}
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)
321+
const mappedEvent = mapSnapshot({ id: config.id, snapshot })
322+
return mappedEvent === undefined
323+
? Effect.void
324+
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
325+
Effect.catchTag("StoppedError", () => Effect.void)
326+
)
325327
})
326328
)
327329
),
328-
Effect.forkIn(scope),
329-
Effect.asVoid
330+
Effect.forkChild
330331
)
332+
const installed = yield* Ref.modify(invokeSessions, (sessions) => {
333+
const current = HashMap.get(sessions, key)
334+
if (Option.isNone(current) || current.value.token !== token) {
335+
return [false, sessions] as const
336+
}
337+
return [
338+
true,
339+
HashMap.set(sessions, key, { ...current.value, watcher })
340+
] as const
341+
})
342+
if (!installed) {
343+
yield* Fiber.interrupt(watcher)
344+
}
331345
})
332346
const startInvoke = Effect.fnUntraced(function*<StateId extends Machine.StateIdentifier<States>>(
333347
path: StateId,
@@ -337,13 +351,11 @@ const makeProcessLogic: <
337351
const invokeId = String(config.id)
338352
const key = makeInvokeSessionKey(path, invokeId)
339353
const childId = config.address === undefined ? makeInvokeChildId(path, invokeId) : String(config.address)
340-
const scope = yield* Scope.make("parallel")
341354
const reserved = yield* Ref.modify(invokeSessions, (sessions) =>
342355
HashMap.has(sessions, key)
343356
? [false, sessions] as const
344-
: [true, HashMap.set(sessions, key, { token, scope, childId, path })] as const)
357+
: [true, HashMap.set(sessions, key, { token, watcher: undefined, childId, path })] as const)
345358
if (!reserved) {
346-
yield* Scope.close(scope, Exit.void)
347359
return yield* Effect.fail(new ChildAlreadyExistsError({ id: invokeId }))
348360
}
349361
const logic = config.src()
@@ -358,24 +370,19 @@ const makeProcessLogic: <
358370
initial: (childScope) => logic.initial({ ...childScope, sendParent }),
359371
run: (childContext) => logic.run({ ...childContext, sendParent })
360372
},
361-
config.descriptor === undefined
362-
? { id: childId }
363-
: { id: childId, descriptor: config.descriptor }
373+
{
374+
id: childId,
375+
...(config.descriptor === undefined ? undefined : { descriptor: config.descriptor }),
376+
onOutcome: (outcome) => handleInvokeOutcome(config, key, token, outcome)
377+
}
364378
).pipe(
365379
Effect.onExit((exit) =>
366380
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-
)
381+
? removeInvoke(key, token)
375382
: Effect.void
376383
)
377384
)
378-
yield* startInvokeWatchers(config, child, key, token, scope)
385+
yield* startInvokeSnapshotWatcher(config, child, key, token)
379386
})
380387
const startInvokes: (
381388
configuration: Model.ActiveConfiguration,
@@ -412,7 +419,7 @@ const makeProcessLogic: <
412419
internalPlanner.sortExitPaths(machine, paths).flatMap((path) =>
413420
HashMap.toEntries(sessions)
414421
.filter(([, session]) => session.path === path)
415-
.map(([key]) => stopInvoke(key, Exit.void))
422+
.map(([key]) => stopInvoke(key))
416423
),
417424
{ discard: true, concurrency: "unbounded" }
418425
)
@@ -481,7 +488,7 @@ const makeProcessLogic: <
481488

482489
if (planned.done) {
483490
terminal = { output: planned.output as Output }
484-
yield* stopAllInvokes(Exit.succeed(planned.output))
491+
yield* stopAllInvokes
485492
} else {
486493
if (changed) {
487494
for (const [path, entryEvent] of entryEvents) {
@@ -509,7 +516,7 @@ const makeProcessLogic: <
509516
}
510517
return terminal.output
511518
}).pipe(
512-
Effect.onExit((exit) => stopAllInvokes(exit))
519+
Effect.onExit(() => stopAllInvokes)
513520
)
514521
}),
515522
context

0 commit comments

Comments
 (0)