Skip to content

Commit f550ac0

Browse files
Compact child lifecycle (#58)
1 parent a0d06e0 commit f550ac0

4 files changed

Lines changed: 162 additions & 135 deletions

File tree

.changeset/fast-children-close.md

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 child lifecycle overhead by reserving child starts atomically and specializing zero- and one-item invoke cleanup without weakening parallel finalization.

src/internal/machineProcess.ts

Lines changed: 44 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ const getInvokes = (
5656
return Array.isArray(invokes) ? invokes as ReadonlyArray<AnyInvokeConfig> : [invokes as AnyInvokeConfig]
5757
}
5858

59+
const runSequentialDiscard = <E, R>(
60+
effects: ReadonlyArray<Effect.Effect<void, E, R>>
61+
): Effect.Effect<void, E, R> =>
62+
effects.length === 0
63+
? Effect.void
64+
: effects.length === 1
65+
? effects[0]!
66+
: Effect.all(effects, { discard: true })
67+
68+
const runParallelDiscard = <E, R>(
69+
effects: ReadonlyArray<Effect.Effect<void, E, R>>
70+
): Effect.Effect<void, E, R> =>
71+
effects.length === 0
72+
? Effect.void
73+
: effects.length === 1
74+
? effects[0]!
75+
: Effect.all(effects, { discard: true, concurrency: "unbounded" })
76+
5977
const invokeCapabilityCache = new WeakMap<Machine.Any, boolean>()
6078

6179
const hasInvokeCapability = (machine: Machine.Any): boolean => {
@@ -282,18 +300,11 @@ const makeProcessLogic: <
282300
)
283301
)
284302
const stopInvoke = (key: string): Effect.Effect<void> => removeInvoke(key, undefined)
285-
stopAllInvokes = Effect.sync(() => {
286-
const sessions = Array.from(invokeSessions.values())
303+
stopAllInvokes = Effect.suspend(() => {
304+
const effects = Array.from(invokeSessions.values(), stopInvokeSession)
287305
invokeSessions.clear()
288-
return sessions
289-
}).pipe(
290-
Effect.flatMap((sessions) =>
291-
Effect.all(
292-
sessions.map((session) => stopInvokeSession(session)),
293-
{ discard: true, concurrency: "unbounded" }
294-
)
295-
)
296-
)
306+
return runParallelDiscard(effects)
307+
})
297308
const startInvoke = Effect.fnUntraced(function*<StateId extends Machine.StateIdentifier<States>>(
298309
path: StateId,
299310
config: AnyInvokeConfig
@@ -347,12 +358,12 @@ const makeProcessLogic: <
347358
)
348359
)
349360
})
350-
startInvokes = Effect.fnUntraced(function*(
361+
startInvokes = (
351362
configuration: Model.ActiveConfiguration,
352363
paths: ReadonlyArray<string>,
353364
event: Machine.LifecycleEvent<Events>
354-
) {
355-
yield* Effect.all(
365+
) =>
366+
runSequentialDiscard(
356367
internalPlanner.sortEntryPaths(machine, paths)
357368
.filter((path) => configuration.active.has(path))
358369
.flatMap((path) =>
@@ -367,22 +378,15 @@ const makeProcessLogic: <
367378
config
368379
) as Effect.Effect<void, E | MachineSchemaDecodeError, R>
369380
)
370-
),
371-
{ discard: true }
381+
)
372382
)
373-
})
374383
stopInvokes = (paths) =>
375-
Effect.sync(() =>
376-
internalPlanner.sortExitPaths(machine, paths).flatMap((path) =>
377-
Array.from(invokeSessions.entries())
378-
.filter(([, session]) => session.path === path)
379-
.map(([key]) => key)
380-
)
381-
).pipe(
382-
Effect.flatMap((keys) =>
383-
Effect.all(
384-
keys.map(stopInvoke),
385-
{ discard: true, concurrency: "unbounded" }
384+
Effect.suspend(() =>
385+
runParallelDiscard(
386+
internalPlanner.sortExitPaths(machine, paths).flatMap((path) =>
387+
Array.from(invokeSessions.entries())
388+
.filter(([, session]) => session.path === path)
389+
.map(([key]) => stopInvoke(key))
386390
)
387391
)
388392
)
@@ -569,18 +573,11 @@ const makeProcessLogic: <
569573
)
570574
)
571575
const stopInvoke = (key: string): Effect.Effect<void> => removeInvoke(key, undefined)
572-
const stopAllInvokes: Effect.Effect<void> = Effect.sync(() => {
573-
const sessions = Array.from(invokeSessions.values())
576+
const stopAllInvokes: Effect.Effect<void> = Effect.suspend(() => {
577+
const effects = Array.from(invokeSessions.values(), stopInvokeSession)
574578
invokeSessions.clear()
575-
return sessions
576-
}).pipe(
577-
Effect.flatMap((sessions) =>
578-
Effect.all(
579-
sessions.map((session) => stopInvokeSession(session)),
580-
{ discard: true, concurrency: "unbounded" }
581-
)
582-
)
583-
)
579+
return runParallelDiscard(effects)
580+
})
584581
const startInvoke = Effect.fnUntraced(function*<StateId extends Machine.StateIdentifier<States>>(
585582
path: StateId,
586583
config: AnyInvokeConfig
@@ -643,7 +640,7 @@ const makeProcessLogic: <
643640
paths: ReadonlyArray<string>,
644641
event: Machine.LifecycleEvent<Events>
645642
) {
646-
yield* Effect.all(
643+
yield* runSequentialDiscard(
647644
internalPlanner.sortEntryPaths(machine, paths)
648645
.filter((path) => configuration.active.has(path))
649646
.flatMap((path) =>
@@ -658,22 +655,16 @@ const makeProcessLogic: <
658655
config
659656
) as Effect.Effect<void, E | MachineSchemaDecodeError, R>
660657
)
661-
),
662-
{ discard: true }
658+
)
663659
)
664660
})
665661
const stopInvokes = (paths: ReadonlyArray<string>): Effect.Effect<void> =>
666-
Effect.sync(() =>
667-
internalPlanner.sortExitPaths(machine, paths).flatMap((path) =>
668-
Array.from(invokeSessions.entries())
669-
.filter(([, session]) => session.path === path)
670-
.map(([key]) => key)
671-
)
672-
).pipe(
673-
Effect.flatMap((keys) =>
674-
Effect.all(
675-
keys.map(stopInvoke),
676-
{ discard: true, concurrency: "unbounded" }
662+
Effect.suspend(() =>
663+
runParallelDiscard(
664+
internalPlanner.sortExitPaths(machine, paths).flatMap((path) =>
665+
Array.from(invokeSessions.entries())
666+
.filter(([, session]) => session.path === path)
667+
.map(([key]) => stopInvoke(key))
677668
)
678669
)
679670
)

src/internal/machineRuntime.ts

Lines changed: 72 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -469,56 +469,43 @@ const makeChildRuntime = (
469469
}
470470
}
471471

472-
const close = <A, E>(_exit: Exit.Exit<A, E>): Effect.Effect<void> =>
473-
Effect.sync(() => {
472+
const close = <A, E>(exit: Exit.Exit<A, E>): Effect.Effect<void> =>
473+
Effect.suspend(() => {
474474
if (registry.closed) {
475-
return undefined
475+
return Effect.void
476476
}
477477
registry.closed = true
478478
if (registry.scope === undefined) {
479-
return undefined
479+
return Effect.void
480480
}
481-
const children = Array.from(registry.children.values()).flatMap((entry) =>
482-
entry._tag === "Started" ? [entry.ref] : []
483-
)
484-
return { children, scope: registry.scope }
485-
}).pipe(
486-
Effect.flatMap((resources) =>
487-
resources === undefined
488-
? Effect.void
489-
: Effect.all(
490-
[
491-
...resources.children.map((child) => child.stop),
492-
...(resources.scope === undefined ? [] : [Scope.close(resources.scope, _exit)])
493-
],
494-
{ concurrency: "unbounded", discard: true }
495-
)
496-
)
497-
)
498-
499-
const getOrCreateScope: Effect.Effect<Scope.Closeable | undefined> = Effect.sync(() => {
500-
if (registry.closed) {
501-
return undefined
502-
}
503-
if (registry.scope === undefined) {
504-
registry.scope = Scope.makeUnsafe("parallel")
505-
}
506-
return registry.scope
507-
})
508-
509-
const reserve = (
510-
key: ChildKey,
511-
token: symbol
512-
): Effect.Effect<boolean, ChildAlreadyExistsError> =>
513-
Effect.suspend(() => {
514-
if (registry.closed) {
515-
return Effect.succeed(false)
481+
const finalizers = Scope.closeUnsafe(registry.scope, exit)
482+
let first: Effect.Effect<void> | undefined
483+
let rest: Array<Effect.Effect<void>> | undefined
484+
for (const entry of registry.children.values()) {
485+
if (entry._tag !== "Started") {
486+
continue
487+
}
488+
if (first === undefined) {
489+
first = entry.ref.stop
490+
} else {
491+
rest ??= [first]
492+
rest.push(entry.ref.stop)
493+
}
516494
}
517-
if (typeof key === "string" && registry.children.has(key)) {
518-
return Effect.fail(new ChildAlreadyExistsError({ id: key }))
495+
if (finalizers !== undefined) {
496+
if (first === undefined) {
497+
first = finalizers
498+
} else {
499+
rest ??= [first]
500+
rest.push(finalizers)
501+
}
519502
}
520-
registry.children.set(key, { _tag: "Starting", token })
521-
return Effect.succeed(true)
503+
const cleanup = rest ?? first
504+
return cleanup === undefined
505+
? Effect.void
506+
: Array.isArray(cleanup)
507+
? Effect.all(cleanup, { concurrency: "unbounded", discard: true })
508+
: cleanup
522509
})
523510

524511
const unregister = (
@@ -681,47 +668,50 @@ const makeChildRuntime = (
681668
const token = Symbol()
682669
const key = spawnOptions?.id ?? token
683670
let startedChild: MachineRef<any, any, any, any> | undefined
684-
return getOrCreateScope.pipe(
685-
Effect.flatMap((childScope) =>
686-
childScope === undefined
687-
? Effect.interrupt
688-
: Effect.gen(function*() {
689-
const reserved = yield* reserve(key, token)
690-
if (!reserved) {
691-
return yield* Effect.interrupt
692-
}
693-
return yield* startLogicInternal(logic, {
694-
detached: true,
695-
...(spawnOptions?.id === undefined ? undefined : { id: spawnOptions.id }),
696-
...(spawnOptions?.onOutcome === undefined ? undefined : { onOutcome: spawnOptions.onOutcome }),
697-
...(spawnOptions?.[activeSnapshotObserver] === undefined
698-
? undefined
699-
: { onSnapshot: spawnOptions[activeSnapshotObserver] }),
700-
...(spawnOptions?.[sendParentOverride] === undefined
701-
? undefined
702-
: { sendParent: spawnOptions[sendParentOverride] }),
703-
onReady: (child, requestChildStop) =>
704-
Effect.sync(() => {
705-
startedChild = child
706-
}).pipe(
707-
Effect.andThen(register(key, token, child, spawnOptions?.descriptor)),
708-
Effect.flatMap((registered) => registered ? Effect.void : requestChildStop)
709-
),
710-
onStop: unregister(key, token),
711-
parent: self,
712-
runtime
713-
}).pipe(
714-
Effect.onExit((exit) =>
715-
Exit.isFailure(exit)
716-
? unregister(key, token).pipe(
717-
Effect.andThen(startedChild === undefined ? Effect.void : startedChild.stop)
718-
)
719-
: Effect.void
720-
)
671+
return Effect.suspend((): Effect.Effect<
672+
MachineRef<ChildState, ChildEvent, ChildError, ChildOutput>,
673+
ChildAlreadyExistsError | ChildInitialError,
674+
Exclude<ChildRequirements, Scope.Scope>
675+
> => {
676+
if (registry.closed) {
677+
return Effect.interrupt
678+
}
679+
if (typeof key === "string" && registry.children.has(key)) {
680+
return Effect.fail(new ChildAlreadyExistsError({ id: key }))
681+
}
682+
registry.scope ??= Scope.makeUnsafe("parallel")
683+
registry.children.set(key, { _tag: "Starting", token })
684+
return startLogicInternal(logic, {
685+
detached: true,
686+
...(spawnOptions?.id === undefined ? undefined : { id: spawnOptions.id }),
687+
...(spawnOptions?.onOutcome === undefined ? undefined : { onOutcome: spawnOptions.onOutcome }),
688+
...(spawnOptions?.[activeSnapshotObserver] === undefined
689+
? undefined
690+
: { onSnapshot: spawnOptions[activeSnapshotObserver] }),
691+
...(spawnOptions?.[sendParentOverride] === undefined
692+
? undefined
693+
: { sendParent: spawnOptions[sendParentOverride] }),
694+
onReady: (child, requestChildStop) =>
695+
Effect.sync(() => {
696+
startedChild = child
697+
}).pipe(
698+
Effect.andThen(register(key, token, child, spawnOptions?.descriptor)),
699+
Effect.flatMap((registered) => registered ? Effect.void : requestChildStop)
700+
),
701+
onStop: unregister(key, token),
702+
parent: self,
703+
runtime
704+
}).pipe(
705+
Effect.onExit((exit) =>
706+
Exit.isFailure(exit)
707+
? unregister(key, token).pipe(
708+
Effect.andThen(startedChild === undefined ? Effect.void : startedChild.stop)
721709
)
722-
}).pipe(Scope.provide(childScope))
710+
: Effect.void
711+
),
712+
Scope.provide(registry.scope)
723713
)
724-
) as Effect.Effect<
714+
}) as Effect.Effect<
725715
MachineRef<ChildState, ChildEvent, ChildError, ChildOutput>,
726716
ChildAlreadyExistsError | ChildInitialError,
727717
Exclude<ChildRequirements, Scope.Scope>

test/MachineProcessLifecycle.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,47 @@ describe("machine process lifecycle", () => {
647647
assert.deepStrictEqual(yield* children.anonymous.snapshot, { status: "stopped", state: 0 })
648648
}))
649649

650+
it.effect("keeps child interruption parallel with scoped resource finalization", () =>
651+
Effect.gen(function*() {
652+
const parentScope = yield* Deferred.make<MachineRuntime.ProcessScope<never>>()
653+
const childInterrupted = yield* Deferred.make<void>()
654+
const resourceFinalized = yield* Deferred.make<void>()
655+
const release = yield* Deferred.make<void>()
656+
const parent = yield* MachineRuntime.startProcess({
657+
initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)),
658+
run: () => Effect.never
659+
})
660+
const child = yield* (yield* Deferred.await(parentScope)).spawn(
661+
Machine.logic({
662+
initial: () =>
663+
Effect.acquireRelease(
664+
Effect.void,
665+
() =>
666+
Deferred.succeed(resourceFinalized, undefined).pipe(
667+
Effect.andThen(Deferred.await(release))
668+
)
669+
).pipe(Effect.as(0)),
670+
run: () =>
671+
Effect.never.pipe(
672+
Effect.ensuring(
673+
Deferred.succeed(childInterrupted, undefined).pipe(
674+
Effect.andThen(Deferred.await(release))
675+
)
676+
)
677+
)
678+
}),
679+
{ id: "child" }
680+
)
681+
const stopping = yield* parent.stop.pipe(Effect.forkChild)
682+
683+
yield* Deferred.await(childInterrupted)
684+
yield* Deferred.await(resourceFinalized)
685+
yield* Deferred.succeed(release, undefined)
686+
yield* Fiber.join(stopping)
687+
688+
assert.deepStrictEqual(yield* child.snapshot, { status: "stopped", state: 0 })
689+
}))
690+
650691
it.effect("does not orphan a child when parent stop races child initialization", () =>
651692
Effect.gen(function*() {
652693
const parentScope = yield* Deferred.make<MachineRuntime.ProcessScope<never>>()

0 commit comments

Comments
 (0)