Skip to content

Commit d602202

Browse files
Compact machine worker loops (#46)
1 parent 1aa746c commit d602202

2 files changed

Lines changed: 113 additions & 113 deletions

File tree

.changeset/compact-idle-workers.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+
Compact running machine workers into a single generator loop and allocate emitted-event runtime closures only when a machine emits, reducing idle heap and improving event throughput without changing scheduler yield semantics.

src/internal/machineProcess.ts

Lines changed: 108 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,16 @@ const makeProcessLogic: <
129129
return yield* Model.normalizeSnapshotEffect(machine, entry.snapshot)
130130
}
131131
const planned = yield* internalPlanner.planInitial(machine, ...entry.args)
132-
const runtime = internalPlanner.makeLiveRuntime<Machine.EventOf<Events>, Machine.EmitOf<Emits>>(
133-
machine,
134-
scope
135-
)
136132
yield* internalPlanner.runCommands(
137133
planned.commands,
138134
scope
139135
)
140-
yield* internalPlanner.runEmittedEvents(planned.emittedEvents, runtime)
136+
if (planned.emittedEvents.length > 0) {
137+
yield* internalPlanner.runEmittedEvents(
138+
planned.emittedEvents,
139+
internalPlanner.makeLiveRuntime<Machine.EventOf<Events>, Machine.EmitOf<Emits>>(machine, scope)
140+
)
141+
}
141142
return planned.state
142143
}),
143144
scope
@@ -157,64 +158,60 @@ const makeProcessLogic: <
157158
)
158159
}
159160

160-
const liveRuntime = internalPlanner.makeLiveRuntime<Machine.EventOf<Events>, Machine.EmitOf<Emits>>(
161-
machine,
162-
context
163-
)
164-
165161
if (!hasInvokes) {
166162
// A queued batch is produced entirely by this worker, so its
167163
// configuration is already validated. Drop both caches before
168164
// blocking again so idle machines retain only the public snapshot.
165+
// Keeping the loop in this generator avoids a suspended generator
166+
// per iteration; every iteration still crosses Effect boundaries,
167+
// so the Effect scheduler remains responsible for cooperative yield.
169168
let configuration: Model.ActiveConfiguration | undefined
170169
let pendingEvent: Option.Option<Machine.EventOf<Events>> = Option.none()
171170
let pollEvent: Effect.Effect<Option.Option<Machine.EventOf<Events>>> | undefined
172-
yield* Effect.whileLoop({
173-
while: () => terminal === undefined,
174-
body: () =>
175-
Effect.gen(function*() {
176-
const event = Option.isSome(pendingEvent) ? pendingEvent.value : yield* receive
177-
pendingEvent = Option.none()
178-
let planned
179-
try {
180-
planned = internalPlanner.planConfiguration(
181-
machine,
182-
configuration ?? Model.normalizeConfigurationSync(machine, current),
183-
event
184-
)
185-
} catch (error) {
186-
if (error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError) {
187-
return yield* error
188-
}
189-
throw error
190-
}
191-
configuration = planned.next
171+
let liveRuntime: Runtime<Machine.EventOf<Events>, Machine.EmitOf<Emits>> | undefined
172+
while (terminal === undefined) {
173+
const event = Option.isSome(pendingEvent) ? pendingEvent.value : yield* receive
174+
pendingEvent = Option.none()
175+
let planned
176+
try {
177+
planned = internalPlanner.planConfiguration(
178+
machine,
179+
configuration ?? Model.normalizeConfigurationSync(machine, current),
180+
event
181+
)
182+
} catch (error) {
183+
if (error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError) {
184+
return yield* error
185+
}
186+
throw error
187+
}
188+
configuration = planned.next
192189

193-
if (planned.microsteps.length > 0) {
194-
const next = Model.snapshotFromConfiguration<States>(machine, planned.next)
195-
yield* internalPlanner.runCommands(planned.commands, context)
196-
yield* setState(next)
197-
current = next
198-
yield* internalPlanner.runEmittedEvents(
199-
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
200-
liveRuntime
201-
)
190+
if (planned.microsteps.length > 0) {
191+
const next = Model.snapshotFromConfiguration<States>(machine, planned.next)
192+
yield* internalPlanner.runCommands(planned.commands, context)
193+
yield* setState(next)
194+
current = next
195+
if (planned.emittedEvents.length > 0) {
196+
yield* internalPlanner.runEmittedEvents(
197+
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
198+
liveRuntime ??= internalPlanner.makeLiveRuntime(machine, context)
199+
)
200+
}
202201

203-
if (planned.done) {
204-
terminal = { output: planned.output as Output }
205-
}
206-
}
202+
if (planned.done) {
203+
terminal = { output: planned.output as Output }
204+
}
205+
}
207206

208-
if (terminal === undefined) {
209-
pendingEvent = yield* (pollEvent ??= Queue.poll(mailbox))
210-
if (Option.isNone(pendingEvent)) {
211-
configuration = undefined
212-
pollEvent = undefined
213-
}
214-
}
215-
}),
216-
step: () => undefined
217-
})
207+
if (terminal === undefined) {
208+
pendingEvent = yield* (pollEvent ??= Queue.poll(mailbox))
209+
if (Option.isNone(pendingEvent)) {
210+
configuration = undefined
211+
pollEvent = undefined
212+
}
213+
}
214+
}
218215

219216
if (terminal === undefined) {
220217
return yield* Effect.die(
@@ -414,73 +411,71 @@ const makeProcessLogic: <
414411
configuration = undefined
415412
let pendingEvent: Option.Option<Machine.EventOf<Events>> = Option.none()
416413
let pollEvent: Effect.Effect<Option.Option<Machine.EventOf<Events>>> | undefined
414+
let liveRuntime: Runtime<Machine.EventOf<Events>, Machine.EmitOf<Emits>> | undefined
417415

418-
yield* Effect.whileLoop({
419-
while: () => terminal === undefined,
420-
body: () =>
421-
Effect.gen(function*() {
422-
const event = Option.isSome(pendingEvent) ? pendingEvent.value : yield* receive
423-
pendingEvent = Option.none()
424-
let planned
425-
try {
426-
planned = internalPlanner.planConfiguration(
427-
machine,
428-
configuration ?? Model.normalizeConfigurationSync(machine, current),
429-
event
430-
)
431-
} catch (error) {
432-
if (error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError) {
433-
return yield* error
416+
// Match the compact non-invoke loop while retaining state-scoped
417+
// child lifecycle work at the same ordered Effect boundaries.
418+
while (terminal === undefined) {
419+
const event = Option.isSome(pendingEvent) ? pendingEvent.value : yield* receive
420+
pendingEvent = Option.none()
421+
let planned
422+
try {
423+
planned = internalPlanner.planConfiguration(
424+
machine,
425+
configuration ?? Model.normalizeConfigurationSync(machine, current),
426+
event
427+
)
428+
} catch (error) {
429+
if (error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError) {
430+
return yield* error
431+
}
432+
throw error
433+
}
434+
configuration = planned.next
435+
if (planned.microsteps.length > 0) {
436+
const changed = planned.microsteps.some((step) => step.changed)
437+
const exitPaths = planned.microsteps.flatMap((step) => step.exitPaths)
438+
const entryEvents = new Map<string, Machine.LifecycleEvent<Events>>()
439+
for (const step of planned.microsteps) {
440+
if (step.changed) {
441+
for (const path of step.entryPaths) {
442+
entryEvents.set(path, step.event as Machine.LifecycleEvent<Events>)
434443
}
435-
throw error
436444
}
437-
configuration = planned.next
438-
if (planned.microsteps.length > 0) {
439-
const changed = planned.microsteps.some((step) => step.changed)
440-
const exitPaths = planned.microsteps.flatMap((step) => step.exitPaths)
441-
const entryEvents = new Map<string, Machine.LifecycleEvent<Events>>()
442-
for (const step of planned.microsteps) {
443-
if (step.changed) {
444-
for (const path of step.entryPaths) {
445-
entryEvents.set(path, step.event as Machine.LifecycleEvent<Events>)
446-
}
447-
}
448-
}
445+
}
449446

450-
const next = Model.snapshotFromConfiguration<States>(machine, planned.next)
451-
yield* internalPlanner.runCommands(planned.commands, context)
452-
if (changed) {
453-
yield* stopInvokes(exitPaths)
454-
}
455-
yield* setState(next)
456-
current = next
457-
yield* internalPlanner.runEmittedEvents(
458-
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
459-
liveRuntime
460-
)
447+
const next = Model.snapshotFromConfiguration<States>(machine, planned.next)
448+
yield* internalPlanner.runCommands(planned.commands, context)
449+
if (changed) {
450+
yield* stopInvokes(exitPaths)
451+
}
452+
yield* setState(next)
453+
current = next
454+
if (planned.emittedEvents.length > 0) {
455+
yield* internalPlanner.runEmittedEvents(
456+
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
457+
liveRuntime ??= internalPlanner.makeLiveRuntime(machine, context)
458+
)
459+
}
461460

462-
if (planned.done) {
463-
terminal = { output: planned.output as Output }
464-
yield* stopAllInvokes
465-
} else {
466-
if (changed) {
467-
for (const [path, entryEvent] of entryEvents) {
468-
yield* startInvokes(planned.next, [path], entryEvent)
469-
}
470-
}
471-
}
461+
if (planned.done) {
462+
terminal = { output: planned.output as Output }
463+
yield* stopAllInvokes
464+
} else if (changed) {
465+
for (const [path, entryEvent] of entryEvents) {
466+
yield* startInvokes(planned.next, [path], entryEvent)
472467
}
468+
}
469+
}
473470

474-
if (terminal === undefined) {
475-
pendingEvent = yield* (pollEvent ??= Queue.poll(mailbox))
476-
if (Option.isNone(pendingEvent)) {
477-
configuration = undefined
478-
pollEvent = undefined
479-
}
480-
}
481-
}),
482-
step: () => undefined
483-
})
471+
if (terminal === undefined) {
472+
pendingEvent = yield* (pollEvent ??= Queue.poll(mailbox))
473+
if (Option.isNone(pendingEvent)) {
474+
configuration = undefined
475+
pollEvent = undefined
476+
}
477+
}
478+
}
484479

485480
if (terminal === undefined) {
486481
return yield* Effect.die(

0 commit comments

Comments
 (0)