Skip to content

Commit 7ccfe10

Browse files
Allocate changes publisher lazily (#38)
1 parent 995bb3b commit 7ccfe10

3 files changed

Lines changed: 99 additions & 12 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+
Allocate change-observation resources only when a machine's `changes` stream is first consumed, while preserving snapshot replay, terminal completion, and the existing `MachineRef` API.

src/internal/machineRuntime.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ interface VersionedSnapshot<State, Error, Output> {
8080
readonly revision: number
8181
readonly snapshot: RuntimeSnapshot<State, Error, Output>
8282
readonly terminalizing: boolean
83+
readonly changes: PubSub.PubSub<Take.Take<VersionedSnapshot<State, Error, Output>>> | undefined
8384
}
8485

8586
export type RuntimeOutcome<State, Error = never, Output = never> =
@@ -309,9 +310,6 @@ const startInternal: <
309310
const terminalized = yield* Deferred.make<void>()
310311
const externalFailure = yield* Deferred.make<never, Error>()
311312
const done = yield* Deferred.make<Output, Error | StoppedError>()
312-
const changes = yield* PubSub.unbounded<Take.Take<VersionedSnapshot<State, Error, Output>>>({
313-
replay: 1
314-
})
315313
const childResourcesState = yield* SynchronizedRef.make<ChildResourcesState>({
316314
closed: false,
317315
resources: undefined
@@ -634,6 +632,7 @@ const startInternal: <
634632
const current = yield* SynchronizedRef.make<VersionedSnapshot<State, Error, Output>>({
635633
revision: 0,
636634
terminalizing: false,
635+
changes: undefined,
637636
snapshot: {
638637
status: "active",
639638
state: initial
@@ -642,19 +641,24 @@ const startInternal: <
642641
const publishSnapshot = (
643642
snapshot: VersionedSnapshot<State, Error, Output>
644643
): Effect.Effect<VersionedSnapshot<State, Error, Output>> =>
645-
PubSub.publish(changes, [snapshot] as const).pipe(Effect.as(snapshot))
644+
snapshot.changes === undefined
645+
? Effect.succeed(snapshot)
646+
: PubSub.publish(snapshot.changes, [snapshot] as const).pipe(Effect.as(snapshot))
646647

647-
const completeChanges: Effect.Effect<void> = PubSub.publish(changes, Exit.succeed<void>(undefined)).pipe(
648-
Effect.asVoid
649-
)
648+
const completeChanges = (
649+
snapshot: VersionedSnapshot<State, Error, Output>
650+
): Effect.Effect<void> =>
651+
snapshot.changes === undefined
652+
? Effect.void
653+
: PubSub.publish(snapshot.changes, Exit.succeed<void>(undefined)).pipe(Effect.asVoid)
650654

651655
const completeIfTerminal = (
652656
snapshot: VersionedSnapshot<State, Error, Output>
653657
): Effect.Effect<VersionedSnapshot<State, Error, Output>> => {
654658
if (snapshot.snapshot.status === "active") {
655659
return Effect.succeed(snapshot)
656660
}
657-
return completeChanges.pipe(Effect.as(snapshot))
661+
return completeChanges(snapshot).pipe(Effect.as(snapshot))
658662
}
659663

660664
const publishIfCurrent = (
@@ -694,7 +698,8 @@ const startInternal: <
694698
const versioned = {
695699
revision: current.revision + 1,
696700
snapshot: next,
697-
terminalizing: false
701+
terminalizing: false,
702+
changes: current.changes
698703
}
699704
return [versioned, versioned] as const
700705
}
@@ -719,7 +724,8 @@ const startInternal: <
719724
{
720725
revision: current.revision + 1,
721726
snapshot: f(current.snapshot),
722-
terminalizing: true
727+
terminalizing: true,
728+
changes: current.changes
723729
},
724730
{ ...current, terminalizing: true }
725731
]
@@ -732,7 +738,8 @@ const startInternal: <
732738
SynchronizedRef.updateAndGet(current, (current) => ({
733739
revision: current.revision + 1,
734740
snapshot,
735-
terminalizing: true
741+
terminalizing: true,
742+
changes: current.changes
736743
})).pipe(
737744
Effect.flatMap(publishSnapshot),
738745
Effect.flatMap(completeIfTerminal),
@@ -844,10 +851,27 @@ const startInternal: <
844851
).pipe(Effect.asVoid)
845852
}
846853

847-
yield* publishSnapshot(yield* SynchronizedRef.get(current))
854+
const getOrCreateChanges = SynchronizedRef.modifyEffect(
855+
current,
856+
(current) => {
857+
if (current.snapshot.status !== "active") {
858+
return Effect.succeed([undefined, current] as const)
859+
}
860+
if (current.changes !== undefined) {
861+
return Effect.succeed([current.changes, current] as const)
862+
}
863+
return PubSub.unbounded<Take.Take<VersionedSnapshot<State, Error, Output>>>({ replay: 1 }).pipe(
864+
Effect.map((changes) => [changes, { ...current, changes }] as const)
865+
)
866+
}
867+
)
848868

849869
const changesStream: Stream.Stream<RuntimeSnapshot<State, Error, Output>> = Stream.unwrap(
850870
Effect.gen(function*() {
871+
const changes = yield* getOrCreateChanges
872+
if (changes === undefined) {
873+
return Stream.succeed((yield* SynchronizedRef.get(current)).snapshot)
874+
}
851875
const subscription = yield* PubSub.subscribe(changes)
852876
const captured = yield* SynchronizedRef.get(current)
853877
if (captured.snapshot.status !== "active") {

test/MachineProcessLifecycle.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,64 @@ describe("machine process lifecycle", () => {
114114
assert.deepStrictEqual(yield* ref.snapshot, { status: "stopped", state: 1 })
115115
}))
116116

117+
it.effect("completes a first changes subscription started after terminalization", () =>
118+
Effect.gen(function*() {
119+
const ref = yield* MachineRuntime.startProcess(
120+
Machine.logic({
121+
initial: 1,
122+
run: () => Effect.never
123+
})
124+
)
125+
126+
yield* ref.stop
127+
128+
assert.deepStrictEqual(Array.from(yield* Stream.runCollect(ref.changes)), [
129+
{ status: "stopped", state: 1 }
130+
])
131+
}))
132+
133+
it.effect("does not lose completion when a first changes subscription races terminal publication", () =>
134+
Effect.gen(function*() {
135+
yield* Effect.forEach(
136+
Array.from({ length: 100 }),
137+
() =>
138+
Effect.gen(function*() {
139+
const release = yield* Deferred.make<void>()
140+
const race = yield* Deferred.make<void>()
141+
const ref = yield* MachineRuntime.startProcess(
142+
Machine.logic({
143+
initial: 0,
144+
run: (context) =>
145+
Deferred.await(release).pipe(
146+
Effect.andThen(context.setState(1)),
147+
Effect.as("done")
148+
)
149+
})
150+
)
151+
const changes = yield* Deferred.await(race).pipe(
152+
Effect.andThen(Stream.runCollect(ref.changes)),
153+
Effect.forkChild
154+
)
155+
const completion = yield* Deferred.await(race).pipe(
156+
Effect.andThen(Deferred.succeed(release, void 0)),
157+
Effect.forkChild
158+
)
159+
160+
yield* Deferred.succeed(race, void 0)
161+
const snapshots = Array.from(yield* Fiber.join(changes))
162+
yield* Fiber.join(completion)
163+
164+
assert.deepStrictEqual(snapshots.at(-1), {
165+
status: "done",
166+
state: 1,
167+
output: "done"
168+
})
169+
assert.strictEqual(snapshots.filter((snapshot) => snapshot.status === "done").length, 1)
170+
}),
171+
{ concurrency: "unbounded" }
172+
)
173+
}))
174+
117175
it.effect("releases a child id after that child requests self-stop during initialization", () =>
118176
Effect.gen(function*() {
119177
const firstRunCount = yield* Ref.make(0)

0 commit comments

Comments
 (0)