Skip to content

Commit 902c717

Browse files
Compact child observation
1 parent ea1ea8e commit 902c717

3 files changed

Lines changed: 183 additions & 79 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 the retained memory of `childChanges` observers with a compact ordered handoff that avoids replaying complete child-registry snapshots.

src/internal/machineRuntime.ts

Lines changed: 94 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,50 @@ export const compiledProcess: unique symbol = Symbol.for("effect/Machine/compile
5454
/** @internal */
5555
export const sendParentOverride: unique symbol = Symbol.for("effect/Machine/sendParentOverride")
5656

57-
interface ChildRegistrySnapshot {
58-
readonly closed: boolean
59-
readonly revision: number
60-
readonly children: ReadonlyMap<ChildKey, ChildEntry>
57+
type ChildObservation = Option.Option<MachineRef<any, any, any, any>>
58+
type ChildObservationBatch = [ChildObservation, ...Array<ChildObservation>]
59+
60+
interface ChildObserver {
61+
readonly child: ChildSelector
62+
readonly id: string
63+
values: ChildObservationBatch | undefined
64+
waiter: Deferred.Deferred<void> | undefined
65+
}
66+
67+
const offerChildObservation = (
68+
observer: ChildObserver,
69+
value: ChildObservation
70+
): void => {
71+
if (observer.values === undefined) {
72+
observer.values = [value]
73+
} else {
74+
observer.values.push(value)
75+
}
76+
if (observer.waiter !== undefined) {
77+
const waiter = observer.waiter
78+
observer.waiter = undefined
79+
Deferred.doneUnsafe(waiter, Effect.void)
80+
}
6181
}
6282

83+
const takeChildObservations = (
84+
observer: ChildObserver
85+
): Effect.Effect<ChildObservationBatch> =>
86+
Effect.suspend(() => {
87+
if (observer.values !== undefined) {
88+
const values = observer.values
89+
observer.values = undefined
90+
return Effect.succeed(values)
91+
}
92+
const waiter = Deferred.makeUnsafe<void>()
93+
observer.waiter = waiter
94+
return Deferred.await(waiter).pipe(Effect.andThen(takeChildObservations(observer)))
95+
})
96+
6397
interface ChildRegistry {
6498
closed: boolean
65-
revision: number
6699
readonly children: Map<ChildKey, ChildEntry>
67-
changes: PubSub.PubSub<ChildRegistrySnapshot> | undefined
100+
observers: Set<ChildObserver> | undefined
68101
scope: Scope.Closeable | undefined
69102
}
70103

@@ -395,24 +428,44 @@ const makeChildRuntime = (
395428
Effect.sync(() => {
396429
// Child-registry decisions are synchronous and every access below runs in
397430
// one Effect.sync / Effect.suspend step. Keep the unobserved representation
398-
// compact; a replay PubSub is installed only when childChanges is used.
431+
// compact; selector-specific handoffs are installed only while
432+
// childChanges streams are running.
399433
const registry: ChildRegistry = {
400434
closed: false,
401-
revision: 0,
402435
children: new Map(),
403-
changes: undefined,
436+
observers: undefined,
404437
scope: undefined
405438
}
406439

407-
const snapshot = (registry: ChildRegistry): ChildRegistrySnapshot => ({
408-
closed: registry.closed,
409-
revision: registry.revision,
410-
children: new Map(registry.children)
411-
})
440+
const matches = (
441+
entry: ChildEntry,
442+
child: ChildSelector
443+
): entry is Extract<ChildEntry, { readonly _tag: "Started" }> =>
444+
entry._tag === "Started" && (typeof child === "string" || (
445+
entry.descriptor !== undefined &&
446+
entry.descriptor.id === child.id &&
447+
entry.descriptor.machine === child.machine
448+
))
449+
450+
const selectChild = (
451+
id: string,
452+
child: ChildSelector
453+
): ChildObservation => {
454+
if (registry.closed) {
455+
return Option.none()
456+
}
457+
const entry = registry.children.get(id)
458+
return entry !== undefined && matches(entry, child)
459+
? Option.some(entry.ref)
460+
: Option.none()
461+
}
412462

413463
const publishRegistryChange = (): void => {
414-
if (registry.changes !== undefined) {
415-
PubSub.publishUnsafe(registry.changes, snapshot(registry))
464+
if (registry.observers === undefined) {
465+
return
466+
}
467+
for (const observer of registry.observers) {
468+
offerChildObservation(observer, selectChild(observer.id, observer.child))
416469
}
417470
}
418471

@@ -478,9 +531,6 @@ const makeChildRuntime = (
478531
return
479532
}
480533
const observable = typeof key === "string"
481-
if (observable) {
482-
registry.revision += 1
483-
}
484534
registry.children.delete(key)
485535
if (observable) {
486536
publishRegistryChange()
@@ -504,9 +554,6 @@ const makeChildRuntime = (
504554
return false
505555
}
506556
const observable = typeof key === "string"
507-
if (observable) {
508-
registry.revision += 1
509-
}
510557
registry.children.delete(key)
511558
registry.children.set(key, { _tag: "Started", token, descriptor, ref })
512559
if (observable) {
@@ -515,16 +562,6 @@ const makeChildRuntime = (
515562
return true
516563
})
517564

518-
const matches = (
519-
entry: ChildEntry,
520-
child: ChildSelector
521-
): entry is Extract<ChildEntry, { readonly _tag: "Started" }> =>
522-
entry._tag === "Started" && (typeof child === "string" || (
523-
entry.descriptor !== undefined &&
524-
entry.descriptor.id === child.id &&
525-
entry.descriptor.machine === child.machine
526-
))
527-
528565
const get: ChildRuntime["get"] = (child) => {
529566
const id = typeof child === "string" ? child : child.id
530567
return Effect.sync(() => {
@@ -540,51 +577,34 @@ const makeChildRuntime = (
540577

541578
const changes: ChildRuntime["changes"] = (child) => {
542579
const id = typeof child === "string" ? child : child.id
543-
return Stream.unwrap(
544-
Effect.suspend(() => {
545-
if (registry.closed) {
546-
return Effect.succeed(undefined)
547-
}
548-
if (registry.changes !== undefined) {
549-
return Effect.succeed(registry.changes)
550-
}
551-
return PubSub.unbounded<ChildRegistrySnapshot>({ replay: 1 }).pipe(
552-
Effect.flatMap((candidate) =>
553-
Effect.sync(() => {
554-
if (registry.closed) {
555-
return [undefined, true] as const
556-
}
557-
if (registry.changes !== undefined) {
558-
return [registry.changes, true] as const
580+
return Stream.fromChannel(
581+
Channel.fromTransform((_, streamScope) =>
582+
Effect.sync((): ChildObserver => ({ child, id, values: undefined, waiter: undefined })).pipe(
583+
Effect.flatMap((observer) => {
584+
const removeObserver = Effect.sync(() => {
585+
if (registry.observers !== undefined) {
586+
registry.observers.delete(observer)
587+
if (registry.observers.size === 0) {
588+
registry.observers = undefined
589+
}
559590
}
560-
registry.changes = candidate
561-
PubSub.publishUnsafe(candidate, snapshot(registry))
562-
return [candidate, false] as const
563-
}).pipe(
564-
Effect.flatMap(([changes, discardCandidate]) =>
565-
discardCandidate
566-
? PubSub.shutdown(candidate).pipe(Effect.as(changes))
567-
: Effect.succeed(changes)
568-
)
591+
observer.values = undefined
592+
observer.waiter = undefined
593+
})
594+
return Scope.addFinalizer(streamScope, removeObserver).pipe(
595+
Effect.andThen(
596+
Effect.sync(() => {
597+
if (!registry.closed && streamScope.state._tag !== "Closed") {
598+
registry.observers ??= new Set()
599+
registry.observers.add(observer)
600+
}
601+
offerChildObservation(observer, selectChild(id, child))
602+
})
603+
),
604+
Effect.as(takeChildObservations(observer))
569605
)
570-
)
606+
})
571607
)
572-
}).pipe(
573-
Effect.flatMap((changes) => {
574-
if (changes === undefined) {
575-
return Effect.succeed(noChildChanges)
576-
}
577-
const select = (registry: ChildRegistrySnapshot) => {
578-
if (registry.closed) {
579-
return Option.none()
580-
}
581-
const entry = registry.children.get(id)
582-
return entry !== undefined && matches(entry, child)
583-
? Option.some(entry.ref)
584-
: Option.none()
585-
}
586-
return Effect.succeed(Stream.fromPubSub(changes).pipe(Stream.map(select)))
587-
})
588608
)
589609
)
590610
}

test/MachineProcessLifecycle.test.ts

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -510,26 +510,105 @@ describe("machine process lifecycle", () => {
510510
run: () => Effect.never
511511
})
512512
const scope = yield* Deferred.await(parentScope)
513-
const observed = yield* parent.childChanges("worker").pipe(
513+
const observeReplacements = parent.childChanges("worker").pipe(
514514
Stream.map(Option.map((child) => child.sessionId)),
515515
Stream.take(5),
516-
Stream.runCollect,
517-
Effect.forkChild
516+
Stream.runCollect
518517
)
518+
const observers = [
519+
yield* observeReplacements.pipe(Effect.forkChild),
520+
yield* observeReplacements.pipe(Effect.forkChild)
521+
]
519522
yield* Effect.yieldNow
520523

521524
const first = yield* scope.spawn(Machine.logic({ initial: 1, run: () => Effect.never }), { id: "worker" })
522525
yield* first.stop
523526
const second = yield* scope.spawn(Machine.logic({ initial: 2, run: () => Effect.never }), { id: "worker" })
524527
yield* second.stop
525528

529+
for (const observer of observers) {
530+
assert.deepStrictEqual(
531+
Array.from(yield* Fiber.join(observer)).map(Option.getOrElse(() => "none")),
532+
["none", first.sessionId, "none", second.sessionId, "none"]
533+
)
534+
}
535+
yield* parent.stop
536+
}))
537+
538+
it.effect("preserves registry-wide childChanges emission ticks", () =>
539+
Effect.gen(function*() {
540+
const parentScope = yield* Deferred.make<MachineRuntime.ProcessScope<never>>()
541+
const parent = yield* MachineRuntime.startProcess({
542+
initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)),
543+
run: () => Effect.never
544+
})
545+
const scope = yield* Deferred.await(parentScope)
546+
const observed = yield* parent.childChanges("worker").pipe(
547+
Stream.take(3),
548+
Stream.runCollect,
549+
Effect.forkChild
550+
)
551+
yield* Effect.yieldNow
552+
553+
const unrelated = yield* scope.spawn(
554+
Machine.logic({ initial: 0, run: () => Effect.never }),
555+
{ id: "unrelated" }
556+
)
557+
yield* unrelated.stop
558+
526559
assert.deepStrictEqual(
527-
Array.from(yield* Fiber.join(observed)).map(Option.getOrElse(() => "none")),
528-
["none", first.sessionId, "none", second.sessionId, "none"]
560+
Array.from(yield* Fiber.join(observed)).map(Option.isNone),
561+
[true, true, true]
529562
)
530563
yield* parent.stop
531564
}))
532565

566+
it.effect("buffers ordered childChanges while a subscriber is stalled", () =>
567+
Effect.gen(function*() {
568+
const parentScope = yield* Deferred.make<MachineRuntime.ProcessScope<never>>()
569+
const parent = yield* MachineRuntime.startProcess({
570+
initial: (scope) => Deferred.succeed(parentScope, scope).pipe(Effect.as(undefined)),
571+
run: () => Effect.never
572+
})
573+
const scope = yield* Deferred.await(parentScope)
574+
const initialObserved = yield* Deferred.make<void>()
575+
const releaseObserver = yield* Deferred.make<void>()
576+
const observationCount = yield* Ref.make(0)
577+
const replacements = 20
578+
const observed = yield* parent.childChanges("worker").pipe(
579+
Stream.mapEffect((child) =>
580+
Ref.getAndUpdate(observationCount, (count) => count + 1).pipe(
581+
Effect.flatMap((index) =>
582+
index === 0
583+
? Deferred.succeed(initialObserved, void 0).pipe(Effect.as(child))
584+
: Deferred.await(releaseObserver).pipe(Effect.as(child))
585+
)
586+
)
587+
),
588+
Stream.take(1 + replacements * 2),
589+
Stream.runCollect,
590+
Effect.forkChild
591+
)
592+
yield* Deferred.await(initialObserved)
593+
594+
for (let index = 0; index < replacements; index += 1) {
595+
const child = yield* scope.spawn(
596+
Machine.logic({ initial: index, run: () => Effect.never }),
597+
{ id: "worker" }
598+
)
599+
yield* child.stop
600+
}
601+
yield* Deferred.succeed(releaseObserver, void 0)
602+
603+
const values = Array.from(yield* Fiber.join(observed))
604+
assert.strictEqual(values.length, 1 + replacements * 2)
605+
assert(Option.isNone(values[0]!))
606+
for (let index = 1; index < values.length; index += 1) {
607+
assert.strictEqual(Option.isSome(values[index]!), index % 2 === 1)
608+
}
609+
yield* parent.stop
610+
}))
611+
533612
it.effect("stops named and anonymous children exactly once with their parent", () =>
534613
Effect.gen(function*() {
535614
const namedCleanup = yield* Ref.make(0)

0 commit comments

Comments
 (0)