diff --git a/.changeset/light-children-share.md b/.changeset/light-children-share.md new file mode 100644 index 0000000..c6d0fbc --- /dev/null +++ b/.changeset/light-children-share.md @@ -0,0 +1,5 @@ +--- +"@typeonce/effect-machine": patch +--- + +Reduce child-machine ownership memory by consolidating supervision and registry state, tracking anonymous children without per-child scope finalizers, and allocating child observation resources only when consumed. diff --git a/src/internal/machineRuntime.ts b/src/internal/machineRuntime.ts index 283b633..f19a92f 100644 --- a/src/internal/machineRuntime.ts +++ b/src/internal/machineRuntime.ts @@ -41,20 +41,17 @@ type ChildEntry = } type ChildSelector = string | ChildDescriptor +type ChildKey = string | symbol -interface ChildRegistry { +interface ChildRegistrySnapshot { + readonly closed: boolean readonly revision: number - readonly children: HashMap.HashMap + readonly children: HashMap.HashMap } -interface ChildResources { - readonly scope: Scope.Closeable - readonly registry: SubscriptionRef.SubscriptionRef -} - -interface ChildResourcesState { - readonly closed: boolean - readonly resources: ChildResources | undefined +interface ChildRegistry extends ChildRegistrySnapshot { + readonly changes: SubscriptionRef.SubscriptionRef | undefined + readonly scope: Scope.Closeable | undefined } export type RuntimeSnapshot = @@ -279,9 +276,11 @@ const makeProcessRuntime: Effect.Effect = Effect.sync(() => { interface StartInternalOptions { readonly detached?: boolean - readonly fiberScope?: Scope.Scope readonly id?: string - readonly onReady?: (ref: MachineRef) => Effect.Effect + readonly onReady?: ( + ref: MachineRef, + requestStop: Effect.Effect + ) => Effect.Effect readonly onStop?: Effect.Effect readonly parent?: ProcessAddress readonly runtime: ProcessRuntime @@ -316,37 +315,45 @@ const startInternal: < const termination = yield* Deferred.make() const done = yield* Deferred.make() const awaitCompletion = Deferred.await(done).pipe(Effect.exit, Effect.asVoid) - const childResourcesState = yield* SynchronizedRef.make({ + const childRegistry = yield* SynchronizedRef.make({ closed: false, - resources: undefined + revision: 0, + children: HashMap.empty(), + changes: undefined, + scope: undefined + }) + + const childRegistrySnapshot = (registry: ChildRegistry): ChildRegistrySnapshot => ({ + closed: registry.closed, + revision: registry.revision, + children: registry.children }) - const getOrCreateChildResources: Effect.Effect = SynchronizedRef.modifyEffect( - childResourcesState, - (state) => { - if (state.resources !== undefined || state.closed) { - return Effect.succeed([state.resources, state] as const) + const closeChildren = (_exit: Exit.Exit): Effect.Effect => + SynchronizedRef.modify(childRegistry, (current) => { + if (current.closed) { + return [undefined, current] as const } - return Effect.all({ - scope: Scope.make("parallel"), - registry: SubscriptionRef.make({ revision: 0, children: HashMap.empty() }) - }).pipe( - Effect.map((resources) => [resources, { closed: false, resources }] as const) + if (current.scope === undefined) { + return [undefined, { ...current, closed: true }] as const + } + const children = Array.from(HashMap.values(current.children)).flatMap((entry) => + entry._tag === "Started" ? [entry.ref] : [] ) - } - ) - - const getExistingChildResources: Effect.Effect = SynchronizedRef.get( - childResourcesState - ).pipe(Effect.map((state) => state.resources)) - - const closeChildren = (exit: Exit.Exit): Effect.Effect => - SynchronizedRef.modify(childResourcesState, (state) => - state.closed - ? [undefined, state] as const - : [state.resources, { ...state, closed: true }] as const).pipe( - Effect.flatMap((resources) => resources === undefined ? Effect.void : Scope.close(resources.scope, exit)) + return [{ children, scope: current.scope }, { ...current, closed: true }] as const + }).pipe( + Effect.flatMap((resources) => + resources === undefined + ? Effect.void + : Effect.all( + [ + ...resources.children.map((child) => child.stop), + ...(resources.scope === undefined ? [] : [Scope.close(resources.scope, _exit)]) + ], + { concurrency: "unbounded", discard: true } + ) ) + ) const cleanupStartupFailure = (exit: Exit.Exit): Effect.Effect => Exit.isFailure(exit) @@ -355,61 +362,92 @@ const startInternal: < const cleanup = options.onStop ?? Effect.void - const reserveChildId = ( - registry: SubscriptionRef.SubscriptionRef, - id: string, + const getOrCreateChildScope: Effect.Effect = SynchronizedRef.modifyEffect( + childRegistry, + (current) => { + if (current.closed || current.scope !== undefined) { + return Effect.succeed([current.scope, current] as const) + } + return Scope.make("parallel").pipe( + Effect.map((scope) => [scope, { ...current, scope }] as const) + ) + } + ) + + const reserveChild = ( + key: ChildKey, token: symbol - ): Effect.Effect => - SubscriptionRef.modifyEffect(registry, (current) => - HashMap.has(current.children, id) - ? Effect.fail(new ChildAlreadyExistsError({ id })) - : Effect.succeed( - [undefined, { - revision: current.revision, - children: HashMap.set(current.children, id, { _tag: "Starting", token }) - }] as const - )) + ): Effect.Effect => + SynchronizedRef.modifyEffect(childRegistry, (current) => { + if (current.closed) { + return Effect.succeed([false, current] as const) + } + if (typeof key === "string" && HashMap.has(current.children, key)) { + return Effect.fail(new ChildAlreadyExistsError({ id: key })) + } + return Effect.succeed( + [ + true, + { ...current, children: HashMap.set(current.children, key, { _tag: "Starting", token }) } + ] as const + ) + }) const unregisterChild = ( - registry: SubscriptionRef.SubscriptionRef, - id: string, + key: ChildKey, token: symbol ): Effect.Effect => - SubscriptionRef.modify(registry, (current) => { - const entry = HashMap.get(current.children, id) + SynchronizedRef.modifyEffect(childRegistry, (current) => { + const entry = HashMap.get(current.children, key) if (Option.isNone(entry) || entry.value.token !== token) { - return [undefined, current] as const + return Effect.succeed([undefined, current] as const) } + const observable = typeof key === "string" const next = { - revision: current.revision + 1, - children: HashMap.remove(current.children, id) + ...current, + revision: observable ? current.revision + 1 : current.revision, + children: HashMap.remove(current.children, key) } - return [next, next] as const - }).pipe(Effect.asVoid) + return observable && next.changes !== undefined + ? SubscriptionRef.set(next.changes, childRegistrySnapshot(next)).pipe( + Effect.as([undefined, next] as const) + ) + : Effect.succeed([undefined, next] as const) + }) const registerStartedChild = ( - registry: SubscriptionRef.SubscriptionRef, - id: string, + key: ChildKey, token: symbol, ref: MachineRef, descriptor: ChildDescriptor | undefined ): Effect.Effect => - SubscriptionRef.modify( - registry, + SynchronizedRef.modifyEffect( + childRegistry, (current) => { - const entry = HashMap.get(current.children, id) - if (Option.isNone(entry) || entry.value._tag !== "Starting" || entry.value.token !== token) { - return [false, current] as const + const entry = HashMap.get(current.children, key) + if ( + current.closed || + Option.isNone(entry) || + entry.value._tag !== "Starting" || + entry.value.token !== token + ) { + return Effect.succeed([false, current] as const) } + const observable = typeof key === "string" const next = { - revision: current.revision + 1, + ...current, + revision: observable ? current.revision + 1 : current.revision, children: HashMap.set( - HashMap.remove(current.children, id), - id, + HashMap.remove(current.children, key), + key, { _tag: "Started", token, descriptor, ref } ) } - return [true, next] as const + return observable && next.changes !== undefined + ? SubscriptionRef.set(next.changes, childRegistrySnapshot(next)).pipe( + Effect.as([true, next] as const) + ) + : Effect.succeed([true, next] as const) } ) @@ -427,19 +465,16 @@ const startInternal: < child: ChildSelector ): Effect.Effect>> => { const id = typeof child === "string" ? child : child.id - return getExistingChildResources.pipe( - Effect.flatMap((resources) => - resources === undefined - ? Effect.succeed(Option.none()) - : SubscriptionRef.get(resources.registry).pipe( - Effect.map((registry) => { - const entry = HashMap.get(registry.children, id) - return Option.isSome(entry) && matchesChildSelector(entry.value, child) - ? Option.some(entry.value.ref as MachineRef) - : Option.none() - }) - ) - ) + return SynchronizedRef.get(childRegistry).pipe( + Effect.map((registry) => { + if (registry.closed) { + return Option.none() + } + const entry = HashMap.get(registry.children, id) + return Option.isSome(entry) && matchesChildSelector(entry.value, child) + ? Option.some(entry.value.ref as MachineRef) + : Option.none() + }) ) } @@ -448,56 +483,65 @@ const startInternal: < ): Stream.Stream>> => { const id = typeof child === "string" ? child : child.id return Stream.unwrap( - getOrCreateChildResources.pipe( - Effect.map((resources) => - resources === undefined - ? Stream.succeed(Option.none()).pipe(Stream.concat(Stream.never)) - : SubscriptionRef.changes(resources.registry).pipe( - Stream.map((registry) => { - const entry = HashMap.get(registry.children, id) - return Option.isSome(entry) && matchesChildSelector(entry.value, child) - ? Option.some(entry.value.ref as MachineRef) - : Option.none() - }) - ) + SynchronizedRef.modifyEffect(childRegistry, (current) => { + if (current.closed) { + return Effect.succeed([undefined, current] as const) + } + if (current.changes !== undefined) { + return Effect.succeed([current.changes, current] as const) + } + return SubscriptionRef.make(childRegistrySnapshot(current)).pipe( + Effect.map((changes) => [changes, { ...current, changes }] as const) ) + }).pipe( + Effect.flatMap((changes) => { + if (changes === undefined) { + return Effect.succeed(Stream.succeed(Option.none()).pipe(Stream.concat(Stream.never))) + } + const selectChild = (registry: ChildRegistrySnapshot) => { + if (registry.closed) { + return Option.none() + } + const entry = HashMap.get(registry.children, id) + return Option.isSome(entry) && matchesChildSelector(entry.value, child) + ? Option.some(entry.value.ref as MachineRef) + : Option.none() + } + return Effect.succeed( + SubscriptionRef.changes(changes).pipe(Stream.map(selectChild)) + ) + }) ) ) } const sendTo = (child: ChildSelector, event: unknown): Effect.Effect => { const id = typeof child === "string" ? child : child.id - return getExistingChildResources.pipe( - Effect.flatMap((resources) => - resources === undefined - ? Effect.void - : SubscriptionRef.get(resources.registry).pipe( - Effect.flatMap((registry) => { - const entry = HashMap.get(registry.children, id) - return Option.isSome(entry) && matchesChildSelector(entry.value, child) - ? entry.value.ref.send(event) - : Effect.void - }) - ) - ) + return SynchronizedRef.get(childRegistry).pipe( + Effect.flatMap((registry) => { + if (registry.closed) { + return Effect.void + } + const entry = HashMap.get(registry.children, id) + return Option.isSome(entry) && matchesChildSelector(entry.value, child) + ? entry.value.ref.send(event) + : Effect.void + }) ) } const stopChild = (child: ChildSelector): Effect.Effect => { const id = typeof child === "string" ? child : child.id - return getExistingChildResources.pipe( - Effect.flatMap((resources) => - resources === undefined - ? Effect.void - : SubscriptionRef.get(resources.registry).pipe( - Effect.flatMap((registry) => { - const entry = HashMap.get(registry.children, id) - return Option.isSome(entry) && matchesChildSelector(entry.value, child) - ? entry.value.ref.stop - : Effect.void - }) - ) - ) + return SynchronizedRef.get(childRegistry).pipe( + Effect.flatMap((registry) => { + if (registry.closed) { + return Effect.void + } + const entry = HashMap.get(registry.children, id) + return Option.isSome(entry) && matchesChildSelector(entry.value, child) + ? entry.value.ref.stop + : Effect.void + }) ) } @@ -533,61 +577,41 @@ const startInternal: < ChildAlreadyExistsError | ChildInitialError, Exclude > { - if (spawnOptions?.id === undefined) { - return getOrCreateChildResources.pipe( - Effect.flatMap((resources) => - resources === undefined - ? Effect.interrupt - : Effect.acquireRelease( - startInternal(logic, { - fiberScope: resources.scope, - parent: self as MachineRef, - runtime: options.runtime - }), - (child) => child.stop - ).pipe(Scope.provide(resources.scope)) - ) - ) as Effect.Effect< - MachineRef, - ChildInitialError, - Exclude - > - } - - const childId = spawnOptions.id - return getOrCreateChildResources.pipe( - Effect.flatMap((resources) => - resources === undefined + const token = Symbol() + const key = spawnOptions?.id ?? token + let startedChild: MachineRef | undefined + return getOrCreateChildScope.pipe( + Effect.flatMap((childScope) => + childScope === undefined ? Effect.interrupt - : Effect.acquireRelease( - Effect.gen(function*() { - const token = Symbol() - yield* reserveChildId(resources.registry, childId, token) - const child = yield* startInternal(logic, { - fiberScope: resources.scope, - id: childId, - onReady: (child) => - registerStartedChild( - resources.registry, - childId, - token, - child, - spawnOptions.descriptor - ).pipe(Effect.asVoid), - onStop: unregisterChild(resources.registry, childId, token), - parent: self as ProcessAddress, - runtime: options.runtime - }).pipe( - Effect.onExit((exit) => - Exit.isFailure(exit) - ? unregisterChild(resources.registry, childId, token) - : Effect.void - ) + : Effect.gen(function*() { + const reserved = yield* reserveChild(key, token) + if (!reserved) { + return yield* Effect.interrupt + } + return yield* startInternal(logic, { + detached: true, + ...(spawnOptions?.id === undefined ? undefined : { id: spawnOptions.id }), + onReady: (child, requestChildStop) => + Effect.sync(() => { + startedChild = child + }).pipe( + Effect.andThen(registerStartedChild(key, token, child, spawnOptions?.descriptor)), + Effect.flatMap((registered) => registered ? Effect.void : requestChildStop) + ), + onStop: unregisterChild(key, token), + parent: self as ProcessAddress, + runtime: options.runtime + }).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? unregisterChild(key, token).pipe( + Effect.andThen(startedChild === undefined ? Effect.void : startedChild.stop) + ) + : Effect.void ) - return child - }), - (child) => child.stop - ).pipe(Scope.provide(resources.scope)) + ) + }).pipe(Scope.provide(childScope)) ) ) as Effect.Effect< MachineRef, @@ -900,7 +924,7 @@ const startInternal: < } if (options.onReady !== undefined) { - yield* options.onReady(ref) + yield* options.onReady(ref, requestStop) } const reserveTermination = (termination: ProcessTermination) => { @@ -929,9 +953,7 @@ const startInternal: < } const forkRuntime = (effect: Effect.Effect) => - options.fiberScope !== undefined - ? Effect.forkIn(effect, options.fiberScope) - : options.detached === true + options.detached === true ? Effect.forkDetach(effect) : Effect.forkChild(effect) diff --git a/test/MachineProcessLifecycle.test.ts b/test/MachineProcessLifecycle.test.ts index 025fd30..46b7965 100644 --- a/test/MachineProcessLifecycle.test.ts +++ b/test/MachineProcessLifecycle.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Cause, Deferred, Effect, Exit, Fiber, Ref, Stream } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Stream } from "effect" import { Machine } from "../src/index.js" import * as MachineRuntime from "../src/internal/machineRuntime.js" @@ -243,6 +243,134 @@ describe("machine process lifecycle", () => { assert.deepStrictEqual(yield* replacement.snapshot, { status: "stopped", state: 2 }) })) + it.effect("stops named and anonymous children exactly once with their parent", () => + Effect.gen(function*() { + const namedCleanup = yield* Ref.make(0) + const anonymousCleanup = yield* Ref.make(0) + const childrenReady = yield* Deferred.make<{ + readonly named: Machine.MachineRef + readonly anonymous: Machine.MachineRef + }>() + const childLogic = (cleanup: Ref.Ref) => + Machine.logic({ + initial: 0, + run: () => + Effect.never.pipe( + Effect.ensuring(Ref.update(cleanup, (count) => count + 1)) + ) + }) + const parent = yield* MachineRuntime.startProcess( + Machine.logic({ + initial: undefined, + run: ({ spawn }) => + Effect.gen(function*() { + const named = yield* spawn(childLogic(namedCleanup), { id: "named" }) + const anonymous = yield* spawn(childLogic(anonymousCleanup)) + yield* Deferred.succeed(childrenReady, { named, anonymous }) + return yield* Effect.never + }) + }) + ) + const children = yield* Deferred.await(childrenReady) + + yield* parent.stop + + assert.strictEqual(yield* Ref.get(namedCleanup), 1) + assert.strictEqual(yield* Ref.get(anonymousCleanup), 1) + assert.deepStrictEqual(yield* children.named.snapshot, { status: "stopped", state: 0 }) + assert.deepStrictEqual(yield* children.anonymous.snapshot, { status: "stopped", state: 0 }) + })) + + it.effect("does not orphan a child when parent stop races child initialization", () => + Effect.gen(function*() { + const parentScope = yield* Deferred.make>() + const childInitializing = yield* Deferred.make() + const releaseChild = yield* Deferred.make() + const resourceCleanup = yield* Ref.make(0) + const parentLogic: MachineRuntime.ProcessLogic = { + initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), + run: () => Effect.never + } + const parent = yield* MachineRuntime.startProcess( + parentLogic + ) + const scope = yield* Deferred.await(parentScope) + const childFiber = yield* scope.spawn( + Machine.logic({ + initial: () => + Effect.acquireRelease( + Effect.void, + () => Ref.update(resourceCleanup, (count) => count + 1) + ).pipe( + Effect.andThen(Deferred.succeed(childInitializing, void 0)), + Effect.andThen(Deferred.await(releaseChild)), + Effect.as(0) + ), + run: () => Effect.never + }), + { id: "racing" } + ).pipe(Effect.forkChild) + + yield* Deferred.await(childInitializing) + yield* parent.stop + yield* Deferred.succeed(releaseChild, void 0) + const child = yield* Fiber.join(childFiber) + yield* Effect.exit(child.join) + + assert.strictEqual(yield* Ref.get(resourceCleanup), 1) + assert.deepStrictEqual(yield* child.snapshot, { status: "stopped", state: 0 }) + assert(Option.isNone(yield* parent.child("racing"))) + })) + + it.effect("does not miss a named child when first observation races registration", () => + Effect.gen(function*() { + yield* Effect.forEach( + Array.from({ length: 50 }), + () => + Effect.gen(function*() { + const parentScope = yield* Deferred.make>() + const race = yield* Deferred.make() + const parentLogic: MachineRuntime.ProcessLogic = { + initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)), + run: () => Effect.never + } + const parent = yield* MachineRuntime.startProcess( + parentLogic + ) + const scope = yield* Deferred.await(parentScope) + const observed = yield* Deferred.await(race).pipe( + Effect.andThen( + parent.childChanges("worker").pipe( + Stream.filter(Option.isSome), + Stream.runHead + ) + ), + Effect.forkChild + ) + const spawned = yield* Deferred.await(race).pipe( + Effect.andThen( + scope.spawn( + Machine.logic({ initial: 0, run: () => Effect.never }), + { id: "worker" } + ) + ), + Effect.forkChild + ) + + yield* Deferred.succeed(race, void 0) + const child = yield* Fiber.join(spawned) + const observation = yield* Fiber.join(observed) + + assert(Option.isSome(observation)) + if (Option.isSome(observation)) { + assert.strictEqual(observation.value.value.sessionId, child.sessionId) + } + yield* parent.stop + }), + { concurrency: 10 } + ) + })) + it.effect("publishes and cleans up exactly once when stop races process completion", () => Effect.gen(function*() { const cleanupCount = yield* Ref.make(0)