Skip to content

Commit 1aa746c

Browse files
Deliver invoked snapshots directly (#45)
1 parent 381cfe3 commit 1aa746c

6 files changed

Lines changed: 137 additions & 65 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+
Deliver invoked child snapshots directly from the child runtime, removing the replay PubSub and watcher fiber previously retained by every snapshot-mapped invocation.

perf/runtime/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@ The fitted heap slope is the primary idle-capacity metric. Compare adjacent
5858
profiles to attribute retained memory: raw process to idle statechart isolates
5959
statechart machinery, two independent machines to parent-with-child isolates
6060
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
61+
registry and invoked-snapshot observation. Invoked snapshot mapping uses a
62+
direct, state-scoped delivery path; its profile measures the retained callback
63+
and mapping state rather than a general `changes` stream subscription. The
64+
Effect profiles are primitive lower bounds, not feature-equivalent competitors.
65+
Resident memory is reported as a raw diagnostic because V8 and the
66+
operating-system allocator can reuse already committed pages. The
6567
capacity-per-GiB value is a linear estimate that excludes shared process
6668
overhead; it is not a run-until-OOM limit.
6769

perf/runtime/counter.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ export const effectMachineAdapter = {
357357
stop: stopObservedChildCounters
358358
},
359359
"snapshot-observed-parent-with-child": {
360-
label: "Parent with invoked child snapshot watcher",
360+
label: "Parent with observed invoked child snapshots",
361361
start: startSnapshotObservedChildCounters,
362362
stop: stopChildCounters
363363
}

src/internal/machineProcess.ts

Lines changed: 24 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,11 @@
66

77
import * as Effect from "effect/Effect"
88
import * as Exit from "effect/Exit"
9-
import * as Fiber from "effect/Fiber"
109
import * as HashMap from "effect/HashMap"
1110
import * as Option from "effect/Option"
1211
import * as Queue from "effect/Queue"
1312
import * as Ref from "effect/Ref"
1413
import type * as Schema from "effect/Schema"
15-
import * as Stream from "effect/Stream"
1614
import type { ActionError, ExecutionServices, Machine, Runtime } from "../Machine.js"
1715
import { ChildAlreadyExistsError, InfiniteTransitionError, MachineSchemaDecodeError } from "./machineErrors.js"
1816
import type { StartupError, StoppedError } from "./machineErrors.js"
@@ -34,7 +32,6 @@ type AnyInvokeConfig = Machine.InvokeConfig<any, any, any, any, any, any, any, a
3432

3533
interface InvokeSession {
3634
readonly token: symbol
37-
readonly watcher: Fiber.Fiber<void> | undefined
3835
readonly childId: string
3936
readonly path: string
4037
}
@@ -240,14 +237,7 @@ const makeProcessLogic: <
240237
return Option.isSome(current) && current.value.token === token
241238
})
242239
)
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-
)
240+
const stopInvokeSession = (session: InvokeSession): Effect.Effect<void> => context.stopChild(session.childId)
251241
const removeInvoke = (
252242
key: string,
253243
token: symbol | undefined
@@ -304,49 +294,25 @@ const makeProcessLogic: <
304294
})
305295
)
306296
}
307-
const startInvokeSnapshotWatcher = Effect.fnUntraced(function*(
297+
const handleInvokeSnapshot = (
308298
config: AnyInvokeConfig,
309-
child: internalRuntime.MachineRef<any, any, any, any>,
310299
key: string,
311-
token: symbol
312-
) {
313-
if (config.snapshot === undefined) {
314-
return
315-
}
316-
const mapSnapshot = config.snapshot
317-
const watcher = yield* child.changes.pipe(
318-
Stream.filter((snapshot) => snapshot.status === "active"),
319-
Stream.runForEach((snapshot) =>
320-
isCurrentInvoke(key, token).pipe(
321-
Effect.flatMap((isCurrent) => {
322-
if (!isCurrent) {
323-
return Effect.void
324-
}
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-
)
331-
})
332-
)
333-
),
334-
Effect.forkChild
300+
token: symbol,
301+
snapshot: Extract<internalRuntime.RuntimeSnapshot<any, any, any>, { readonly status: "active" }>
302+
): Effect.Effect<void> =>
303+
isCurrentInvoke(key, token).pipe(
304+
Effect.flatMap((isCurrent) => {
305+
if (!isCurrent || config.snapshot === undefined) {
306+
return Effect.void
307+
}
308+
const mappedEvent = config.snapshot({ id: config.id, snapshot })
309+
return mappedEvent === undefined
310+
? Effect.void
311+
: context.self.send(mappedEvent as Machine.EventOf<Events>).pipe(
312+
Effect.catchTag("StoppedError", () => Effect.void)
313+
)
314+
})
335315
)
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-
}
349-
})
350316
const startInvoke = Effect.fnUntraced(function*<StateId extends Machine.StateIdentifier<States>>(
351317
path: StateId,
352318
config: AnyInvokeConfig
@@ -358,7 +324,7 @@ const makeProcessLogic: <
358324
const reserved = yield* Ref.modify(invokeSessions, (sessions) =>
359325
HashMap.has(sessions, key)
360326
? [false, sessions] as const
361-
: [true, HashMap.set(sessions, key, { token, watcher: undefined, childId, path })] as const)
327+
: [true, HashMap.set(sessions, key, { token, childId, path })] as const)
362328
if (!reserved) {
363329
return yield* Effect.fail(new ChildAlreadyExistsError({ id: invokeId }))
364330
}
@@ -369,15 +335,19 @@ const makeProcessLogic: <
369335
isCurrent ? context.self.send(event as Machine.EventOf<Events>) : Effect.void
370336
)
371337
)
372-
const child = yield* context.spawn(
338+
yield* context.spawn(
373339
{
374340
initial: (childScope) => logic.initial({ ...childScope, sendParent }),
375341
run: (childContext) => logic.run({ ...childContext, sendParent })
376342
},
377343
{
378344
id: childId,
379345
...(config.descriptor === undefined ? undefined : { descriptor: config.descriptor }),
380-
onOutcome: (outcome) => handleInvokeOutcome(config, key, token, outcome)
346+
onOutcome: (outcome) => handleInvokeOutcome(config, key, token, outcome),
347+
...(config.snapshot === undefined ? undefined : {
348+
[internalRuntime.activeSnapshotObserver]: (snapshot) =>
349+
handleInvokeSnapshot(config, key, token, snapshot)
350+
})
381351
}
382352
).pipe(
383353
Effect.onExit((exit) =>
@@ -386,7 +356,6 @@ const makeProcessLogic: <
386356
: Effect.void
387357
)
388358
)
389-
yield* startInvokeSnapshotWatcher(config, child, key, token)
390359
})
391360
const startInvokes: (
392361
configuration: Model.ActiveConfiguration,

src/internal/machineRuntime.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ type ChildEntry =
4343
type ChildSelector = string | ChildDescriptor
4444
type ChildKey = string | symbol
4545

46+
/** @internal */
47+
export const activeSnapshotObserver: unique symbol = Symbol.for("effect/Machine/activeSnapshotObserver")
48+
4649
interface ChildRegistrySnapshot {
4750
readonly closed: boolean
4851
readonly revision: number
@@ -183,6 +186,9 @@ export interface ProcessSpawn {
183186
readonly onOutcome?: (
184187
outcome: RuntimeOutcome<ChildState, ChildError, ChildOutput>
185188
) => Effect.Effect<void>
189+
readonly [activeSnapshotObserver]?: (
190+
snapshot: Extract<RuntimeSnapshot<ChildState, ChildError, ChildOutput>, { readonly status: "active" }>
191+
) => Effect.Effect<void>
186192
}
187193
): Effect.Effect<
188194
MachineRef<ChildState, ChildEvent, ChildError, ChildOutput>,
@@ -257,6 +263,17 @@ const classifyOutcome = <State, Error, Output>(
257263
}
258264
}
259265

266+
const notifyActiveSnapshot = <State, Error, Output>(
267+
onSnapshot: (
268+
snapshot: Extract<RuntimeSnapshot<State, Error, Output>, { readonly status: "active" }>
269+
) => Effect.Effect<void>,
270+
snapshot: Extract<RuntimeSnapshot<State, Error, Output>, { readonly status: "active" }>
271+
): Effect.Effect<void> =>
272+
Effect.suspend(() => onSnapshot(snapshot)).pipe(
273+
Effect.exit,
274+
Effect.asVoid
275+
)
276+
260277
export const watch = <State, Event, Error = never, Output = never>(
261278
ref: MachineRef<State, Event, Error, Output>
262279
): Stream.Stream<RuntimeOutcome<State, Error, Output>> =>
@@ -281,6 +298,9 @@ interface StartInternalOptions {
281298
readonly detached?: boolean
282299
readonly id?: string
283300
readonly onOutcome?: (outcome: RuntimeOutcome<any, any, any>) => Effect.Effect<void>
301+
readonly onSnapshot?: (
302+
snapshot: Extract<RuntimeSnapshot<any, any, any>, { readonly status: "active" }>
303+
) => Effect.Effect<void>
284304
readonly onReady?: (
285305
ref: MachineRef<any, any, any, any>,
286306
requestStop: Effect.Effect<void>
@@ -567,6 +587,9 @@ const startInternal: <
567587
readonly onOutcome?: (
568588
outcome: RuntimeOutcome<ChildState, ChildError, ChildOutput>
569589
) => Effect.Effect<void>
590+
readonly [activeSnapshotObserver]?: (
591+
snapshot: Extract<RuntimeSnapshot<ChildState, ChildError, ChildOutput>, { readonly status: "active" }>
592+
) => Effect.Effect<void>
570593
}
571594
): Effect.Effect<
572595
MachineRef<ChildState, ChildEvent, ChildError, ChildOutput>,
@@ -581,6 +604,9 @@ const startInternal: <
581604
readonly onOutcome?: (
582605
outcome: RuntimeOutcome<ChildState, ChildError, ChildOutput>
583606
) => Effect.Effect<void>
607+
readonly [activeSnapshotObserver]?: (
608+
snapshot: Extract<RuntimeSnapshot<ChildState, ChildError, ChildOutput>, { readonly status: "active" }>
609+
) => Effect.Effect<void>
584610
}
585611
): Effect.Effect<
586612
MachineRef<ChildState, ChildEvent, ChildError, ChildOutput>,
@@ -603,6 +629,9 @@ const startInternal: <
603629
detached: true,
604630
...(spawnOptions?.id === undefined ? undefined : { id: spawnOptions.id }),
605631
...(spawnOptions?.onOutcome === undefined ? undefined : { onOutcome: spawnOptions.onOutcome }),
632+
...(spawnOptions?.[activeSnapshotObserver] === undefined
633+
? undefined
634+
: { onSnapshot: spawnOptions[activeSnapshotObserver] }),
606635
onReady: (child, requestChildStop) =>
607636
Effect.sync(() => {
608637
startedChild = child
@@ -681,12 +710,22 @@ const startInternal: <
681710
state: initial
682711
}
683712
})
684-
const publishSnapshot = (
713+
const publishSnapshot: (
685714
snapshot: VersionedSnapshot<State, Error, Output>
686-
): Effect.Effect<VersionedSnapshot<State, Error, Output>> =>
687-
snapshot.changes === undefined
688-
? Effect.succeed(snapshot)
689-
: PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot))
715+
) => Effect.Effect<VersionedSnapshot<State, Error, Output>> = options.onSnapshot === undefined
716+
? (snapshot) =>
717+
snapshot.changes === undefined
718+
? Effect.succeed(snapshot)
719+
: PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot))
720+
: (snapshot) => {
721+
const publish = snapshot.changes === undefined
722+
? Effect.succeed(snapshot)
723+
: PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot))
724+
const runtimeSnapshot = snapshot.snapshot
725+
return runtimeSnapshot.status !== "active"
726+
? publish
727+
: publish.pipe(Effect.tap(() => notifyActiveSnapshot(options.onSnapshot!, runtimeSnapshot)))
728+
}
690729

691730
const completeChanges = (
692731
snapshot: VersionedSnapshot<State, Error, Output>
@@ -945,6 +984,9 @@ const startInternal: <
945984
if (options.onReady !== undefined) {
946985
yield* options.onReady(ref, requestStop)
947986
}
987+
if (options.onSnapshot !== undefined) {
988+
yield* notifyActiveSnapshot(options.onSnapshot, { status: "active", state: initial })
989+
}
948990

949991
const reserveTermination = (termination: ProcessTermination) => {
950992
switch (termination._tag) {

test/MachineProcessLifecycle.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,60 @@ describe("machine process lifecycle", () => {
433433
yield* parent.stop
434434
}))
435435

436+
it.effect("delivers committed active child snapshots directly and in order", () =>
437+
Effect.gen(function*() {
438+
const parentScope = yield* Deferred.make<MachineRuntime.ProcessScope<never>>()
439+
const snapshots = yield* Ref.make<ReadonlyArray<number>>([])
440+
const parent = yield* MachineRuntime.startProcess({
441+
initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)),
442+
run: () => Effect.never
443+
})
444+
const child = yield* (yield* Deferred.await(parentScope)).spawn(
445+
Machine.logic({
446+
initial: 0,
447+
run: ({ setState }) =>
448+
setState(1).pipe(
449+
Effect.andThen(setState(2)),
450+
Effect.as("output")
451+
)
452+
}),
453+
{
454+
id: "child",
455+
[MachineRuntime.activeSnapshotObserver]: (snapshot) =>
456+
Ref.update(snapshots, (current) => [...current, snapshot.state])
457+
}
458+
)
459+
460+
assert.strictEqual(yield* child.join, "output")
461+
assert.deepStrictEqual(yield* Ref.get(snapshots), [0, 1, 2])
462+
yield* parent.stop
463+
}))
464+
465+
it.effect("isolates active child state updates from snapshot callback defects", () =>
466+
Effect.gen(function*() {
467+
const parentScope = yield* Deferred.make<MachineRuntime.ProcessScope<never>>()
468+
const parent = yield* MachineRuntime.startProcess({
469+
initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)),
470+
run: () => Effect.never
471+
})
472+
const child = yield* (yield* Deferred.await(parentScope)).spawn(
473+
Machine.logic({
474+
initial: 0,
475+
run: ({ setState }) => setState(1).pipe(Effect.as("output"))
476+
}),
477+
{ id: "child", [MachineRuntime.activeSnapshotObserver]: () => Effect.die("callback defect") }
478+
)
479+
480+
assert.strictEqual(yield* child.join, "output")
481+
assert.deepStrictEqual(yield* child.snapshot, {
482+
status: "done",
483+
state: 1,
484+
output: "output"
485+
})
486+
assert.deepStrictEqual(yield* parent.snapshot, { status: "active", state: undefined })
487+
yield* parent.stop
488+
}))
489+
436490
it.effect("publishes and cleans up exactly once when stop races process completion", () =>
437491
Effect.gen(function*() {
438492
const cleanupCount = yield* Ref.make(0)

0 commit comments

Comments
 (0)