Skip to content

Commit 69f1c20

Browse files
Reuse configurations while draining events (#40)
1 parent e60b9d7 commit 69f1c20

5 files changed

Lines changed: 172 additions & 65 deletions

File tree

.changeset/calm-machines-batch.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+
Reuse the validated statechart configuration while draining queued event batches, avoiding repeated snapshot normalization without retaining the cache while a machine is idle. Canonicalize history snapshot paths in machine document order so batched and public planning produce identical snapshots.

src/internal/machineModel.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ const historyFromSnapshotEffect = Effect.fnUntraced(function*(
215215
})
216216

217217
const historyToSnapshot = (
218+
machine: Machine.Any,
218219
history: ReadonlyMap<string, HistoryRecord>
219220
): Readonly<
220221
Record<string, {
@@ -229,10 +230,11 @@ const historyToSnapshot = (
229230
readonly values: Readonly<Record<string, unknown>>
230231
}> = {}
231232
for (const [path, record] of history) {
233+
const active = Array.from(record.active).sort((left, right) => compareDocumentOrder(machine, left, right))
232234
entries[path] = {
233235
mode: record.mode,
234-
active: Array.from(record.active),
235-
values: Object.fromEntries(record.values)
236+
active,
237+
values: Object.fromEntries(active.map((path) => [path, record.values.get(path)]))
236238
}
237239
}
238240
return entries
@@ -1009,7 +1011,7 @@ export const snapshotFromConfiguration = <const States extends Machine.StateSche
10091011
}).completed = completed
10101012
}
10111013
if (configuration.history.size > 0) {
1012-
Object.assign(snapshot, { history: historyToSnapshot(configuration.history) })
1014+
Object.assign(snapshot, { history: historyToSnapshot(machine, configuration.history) })
10131015
}
10141016
return snapshot
10151017
}
@@ -1024,7 +1026,7 @@ export const snapshotFromConfigurationAtPath = <const States extends Machine.Sta
10241026
): Machine.SnapshotByIdentifier<States, Machine.StateIdentifier<States>> => {
10251027
const snapshot = snapshotFromPath<States>(machine, configuration, path)
10261028
if (configuration.history.size > 0) {
1027-
Object.assign(snapshot, { history: historyToSnapshot(configuration.history) })
1029+
Object.assign(snapshot, { history: historyToSnapshot(machine, configuration.history) })
10281030
}
10291031
return snapshot
10301032
}

src/internal/machinePlanner.ts

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2107,7 +2107,7 @@ const settle: <
21072107
: { ...result, done: false, output: undefined }
21082108
})
21092109

2110-
const macrostep: <
2110+
const macrostepConfiguration: <
21112111
const States extends Machine.StateSchemas,
21122112
const Events extends ReadonlyArray<Machine.TaggedSchema>,
21132113
const Emits extends ReadonlyArray<Machine.TaggedSchema> = readonly [],
@@ -2121,10 +2121,10 @@ const macrostep: <
21212121
Output = never
21222122
>(
21232123
machine: Machine<States, Events, Input, UnhandledStates, E, R, InitialE, InitialR, FinalStates, Output, Emits>,
2124-
state: Machine.Snapshot<States>,
2124+
configuration: ActiveConfiguration,
21252125
event: Machine.EventOf<Events>
21262126
) => Effect.Effect<
2127-
MacrostepPlan<Machine.Snapshot<States>, Machine.EventOf<Events>, E, R, Output>,
2127+
MacrostepPlan<ActiveConfiguration, Machine.EventOf<Events>, E, R, Output>,
21282128
E | InfiniteTransitionError | MachineSchemaDecodeError,
21292129
R
21302130
> = Effect.fnUntraced(function*<
@@ -2141,10 +2141,9 @@ const macrostep: <
21412141
Output = never
21422142
>(
21432143
machine: Machine<States, Events, Input, UnhandledStates, E, R, InitialE, InitialR, FinalStates, Output, Emits>,
2144-
state: Machine.Snapshot<States>,
2144+
configuration: ActiveConfiguration,
21452145
event: Machine.EventOf<Events>
21462146
) {
2147-
const configuration = yield* normalizeConfigurationEffect<States>(machine, state)
21482147
const decodedEvent = yield* decodeEvent<Events>(machine, event)
21492148
if (isActiveFinalConfiguration(machine, configuration)) {
21502149
const completed = yield* completeConfigurationEffect(machine, configuration, decodedEvent)
@@ -2155,7 +2154,7 @@ const macrostep: <
21552154
)
21562155
}
21572156
return {
2158-
next: snapshotFromConfiguration<States>(machine, completed.configuration),
2157+
next: completed.configuration,
21592158
actions: [],
21602159
emittedEvents: [],
21612160
microsteps: [],
@@ -2171,7 +2170,7 @@ const macrostep: <
21712170
)
21722171
if (selections.length === 0) {
21732172
return {
2174-
next: snapshotFromConfiguration<States>(machine, configuration),
2173+
next: configuration,
21752174
actions: [],
21762175
emittedEvents: [],
21772176
microsteps: [],
@@ -2189,7 +2188,19 @@ const macrostep: <
21892188
const raisedEvents = [...step.raisedEvents]
21902189
const emittedEvents = [...step.emittedEvents]
21912190
const microsteps = [step]
2192-
const settled = yield* settle(machine, step.next, decodedEvent, actions, raisedEvents, emittedEvents, microsteps)
2191+
return yield* settle(machine, step.next, decodedEvent, actions, raisedEvents, emittedEvents, microsteps)
2192+
})
2193+
2194+
const snapshotMacrostep = <
2195+
const States extends Machine.StateSchemas,
2196+
Event,
2197+
E,
2198+
R,
2199+
Output
2200+
>(
2201+
machine: Machine.Any,
2202+
settled: MacrostepPlan<ActiveConfiguration, Event, E, R, Output>
2203+
): MacrostepPlan<Machine.Snapshot<States>, Event, E, R, Output> => {
21932204
const planned = {
21942205
next: snapshotFromConfiguration<States>(machine, settled.next),
21952206
actions: settled.actions,
@@ -2209,10 +2220,54 @@ const macrostep: <
22092220
return settled.done
22102221
? { ...planned, done: true, output: settled.output }
22112222
: { ...planned, done: false, output: undefined }
2223+
}
2224+
2225+
const macrostep: <
2226+
const States extends Machine.StateSchemas,
2227+
const Events extends ReadonlyArray<Machine.TaggedSchema>,
2228+
const Emits extends ReadonlyArray<Machine.TaggedSchema> = readonly [],
2229+
const Input extends Schema.Top = typeof Schema.Void,
2230+
UnhandledStates extends Machine.StateIdentifier<States> = Machine.StateIdentifier<States>,
2231+
E = never,
2232+
R = never,
2233+
InitialE = never,
2234+
InitialR = never,
2235+
FinalStates extends Machine.StateIdentifier<States> = never,
2236+
Output = never
2237+
>(
2238+
machine: Machine<States, Events, Input, UnhandledStates, E, R, InitialE, InitialR, FinalStates, Output, Emits>,
2239+
state: Machine.Snapshot<States>,
2240+
event: Machine.EventOf<Events>
2241+
) => Effect.Effect<
2242+
MacrostepPlan<Machine.Snapshot<States>, Machine.EventOf<Events>, E, R, Output>,
2243+
E | InfiniteTransitionError | MachineSchemaDecodeError,
2244+
R
2245+
> = Effect.fnUntraced(function*<
2246+
const States extends Machine.StateSchemas,
2247+
const Events extends ReadonlyArray<Machine.TaggedSchema>,
2248+
const Emits extends ReadonlyArray<Machine.TaggedSchema> = readonly [],
2249+
const Input extends Schema.Top = typeof Schema.Void,
2250+
UnhandledStates extends Machine.StateIdentifier<States> = Machine.StateIdentifier<States>,
2251+
E = never,
2252+
R = never,
2253+
InitialE = never,
2254+
InitialR = never,
2255+
FinalStates extends Machine.StateIdentifier<States> = never,
2256+
Output = never
2257+
>(
2258+
machine: Machine<States, Events, Input, UnhandledStates, E, R, InitialE, InitialR, FinalStates, Output, Emits>,
2259+
state: Machine.Snapshot<States>,
2260+
event: Machine.EventOf<Events>
2261+
) {
2262+
const configuration = yield* normalizeConfigurationEffect<States>(machine, state)
2263+
const settled = yield* macrostepConfiguration(machine, configuration, event)
2264+
return snapshotMacrostep<States, Machine.EventOf<Events>, E, R, Output>(machine, settled)
22122265
})
22132266

22142267
export const plan = macrostep
22152268

2269+
export const planConfiguration = macrostepConfiguration
2270+
22162271
const actionUnsafe = Effect.fnUntraced(function*<E, R>(
22172272
effect: Effect.Effect<void, E, R>
22182273
) {

src/internal/machineProcess.ts

Lines changed: 96 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"
88
import * as Exit from "effect/Exit"
99
import * as HashMap from "effect/HashMap"
1010
import * as Option from "effect/Option"
11+
import * as Queue from "effect/Queue"
1112
import * as Ref from "effect/Ref"
1213
import type * as Schema from "effect/Schema"
1314
import * as Scope from "effect/Scope"
@@ -147,14 +148,14 @@ const makeProcessLogic: <
147148
run: (context) =>
148149
internalRuntime.provideMachineRuntime(
149150
Effect.gen(function*() {
150-
const { receive, state, setState } = context
151+
const { mailbox, receive, state, setState } = context
151152
let terminal: { readonly output: Output } | undefined
152153

153-
const initialState = yield* state
154-
if (internalPlanner.isFinalState(machine, initialState)) {
154+
let current = yield* state
155+
if (internalPlanner.isFinalState(machine, current)) {
155156
return yield* internalPlanner.getFinalOutputEffect<States, Events, Output>(
156157
machine,
157-
initialState,
158+
current,
158159
internalPlanner.InitialEvent
159160
)
160161
}
@@ -165,26 +166,46 @@ const makeProcessLogic: <
165166
)
166167

167168
if (!hasInvokes) {
169+
// A queued batch is produced entirely by this worker, so its
170+
// configuration is already validated. Drop both caches before
171+
// blocking again so idle machines retain only the public snapshot.
172+
let configuration: Model.ActiveConfiguration | undefined
173+
let pendingEvent: Option.Option<Machine.EventOf<Events>> = Option.none()
174+
let pollEvent: Effect.Effect<Option.Option<Machine.EventOf<Events>>> | undefined
168175
yield* Effect.whileLoop({
169176
while: () => terminal === undefined,
170177
body: () =>
171178
Effect.gen(function*() {
172-
const event = yield* receive
173-
const current = yield* state
174-
const planned = yield* internalPlanner.plan(machine, current, event)
175-
if (planned.microsteps.length === 0) {
176-
return
177-
}
178-
179-
yield* internalPlanner.runActions(planned.actions, liveRuntime)
180-
yield* setState(planned.next)
181-
yield* internalPlanner.runEmittedEvents(
182-
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
183-
liveRuntime
179+
const event = Option.isSome(pendingEvent) ? pendingEvent.value : yield* receive
180+
pendingEvent = Option.none()
181+
const planned = yield* internalPlanner.planConfiguration(
182+
machine,
183+
configuration ?? (yield* Model.normalizeConfigurationEffect(machine, current)),
184+
event
184185
)
186+
configuration = planned.next
187+
188+
if (planned.microsteps.length > 0) {
189+
const next = Model.snapshotFromConfiguration<States>(machine, planned.next)
190+
yield* internalPlanner.runActions(planned.actions, liveRuntime)
191+
yield* setState(next)
192+
current = next
193+
yield* internalPlanner.runEmittedEvents(
194+
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
195+
liveRuntime
196+
)
185197

186-
if (planned.done) {
187-
terminal = { output: planned.output }
198+
if (planned.done) {
199+
terminal = { output: planned.output }
200+
}
201+
}
202+
203+
if (terminal === undefined) {
204+
pendingEvent = yield* (pollEvent ??= Queue.poll(mailbox))
205+
if (Option.isNone(pendingEvent)) {
206+
configuration = undefined
207+
pollEvent = undefined
208+
}
188209
}
189210
}),
190211
step: () => undefined
@@ -349,15 +370,14 @@ const makeProcessLogic: <
349370
yield* startInvokeWatchers(config, child, key, token, scope)
350371
})
351372
const startInvokes: (
352-
state: Machine.Snapshot<States>,
373+
configuration: Model.ActiveConfiguration,
353374
paths: ReadonlyArray<string>,
354375
event: Machine.LifecycleEvent<Events>
355376
) => Effect.Effect<void, E | MachineSchemaDecodeError, R> = Effect.fnUntraced(function*(
356-
state: Machine.Snapshot<States>,
377+
configuration: Model.ActiveConfiguration,
357378
paths: ReadonlyArray<string>,
358379
event: Machine.LifecycleEvent<Events>
359380
) {
360-
const configuration = yield* Model.normalizeConfigurationEffect(machine, state)
361381
yield* Effect.all(
362382
internalPlanner.sortEntryPaths(machine, paths)
363383
.filter((path) => configuration.active.has(path))
@@ -393,53 +413,76 @@ const makeProcessLogic: <
393413
)
394414

395415
return yield* Effect.gen(function*() {
416+
let configuration: Model.ActiveConfiguration | undefined = yield* Model.normalizeConfigurationEffect(
417+
machine,
418+
current
419+
)
396420
yield* startInvokes(
397-
initialState,
398-
Model.getInitialEntryPaths(machine, yield* Model.normalizeConfigurationEffect(machine, initialState)),
421+
configuration,
422+
Model.getInitialEntryPaths(machine, configuration),
399423
internalPlanner.InitialEvent
400424
)
425+
// As above, keep the normalized configuration only while this
426+
// worker can continue draining an already queued batch.
427+
configuration = undefined
428+
let pendingEvent: Option.Option<Machine.EventOf<Events>> = Option.none()
429+
let pollEvent: Effect.Effect<Option.Option<Machine.EventOf<Events>>> | undefined
401430

402431
yield* Effect.whileLoop({
403432
while: () => terminal === undefined,
404433
body: () =>
405434
Effect.gen(function*() {
406-
const event = yield* receive
407-
const current = yield* state
408-
const planned = yield* internalPlanner.plan(machine, current, event)
409-
if (planned.microsteps.length === 0) {
410-
return
411-
}
412-
const changed = planned.microsteps.some((step) => step.changed)
413-
const exitPaths = planned.microsteps.flatMap((step) => step.exitPaths)
414-
const entryEvents = new Map<string, Machine.LifecycleEvent<Events>>()
415-
for (const step of planned.microsteps) {
416-
if (step.changed) {
417-
for (const path of step.entryPaths) {
418-
entryEvents.set(path, step.event as Machine.LifecycleEvent<Events>)
435+
const event = Option.isSome(pendingEvent) ? pendingEvent.value : yield* receive
436+
pendingEvent = Option.none()
437+
const planned = yield* internalPlanner.planConfiguration(
438+
machine,
439+
configuration ?? (yield* Model.normalizeConfigurationEffect(machine, current)),
440+
event
441+
)
442+
configuration = planned.next
443+
if (planned.microsteps.length > 0) {
444+
const changed = planned.microsteps.some((step) => step.changed)
445+
const exitPaths = planned.microsteps.flatMap((step) => step.exitPaths)
446+
const entryEvents = new Map<string, Machine.LifecycleEvent<Events>>()
447+
for (const step of planned.microsteps) {
448+
if (step.changed) {
449+
for (const path of step.entryPaths) {
450+
entryEvents.set(path, step.event as Machine.LifecycleEvent<Events>)
451+
}
419452
}
420453
}
421-
}
422-
423-
yield* internalPlanner.runActions(planned.actions, liveRuntime)
424-
if (changed) {
425-
yield* stopInvokes(exitPaths)
426-
}
427-
yield* setState(planned.next)
428-
yield* internalPlanner.runEmittedEvents(
429-
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
430-
liveRuntime
431-
)
432454

433-
if (planned.done) {
434-
terminal = { output: planned.output }
435-
yield* stopAllInvokes(Exit.succeed(planned.output))
436-
} else {
455+
const next = Model.snapshotFromConfiguration<States>(machine, planned.next)
456+
yield* internalPlanner.runActions(planned.actions, liveRuntime)
437457
if (changed) {
438-
for (const [path, entryEvent] of entryEvents) {
439-
yield* startInvokes(planned.next, [path], entryEvent)
458+
yield* stopInvokes(exitPaths)
459+
}
460+
yield* setState(next)
461+
current = next
462+
yield* internalPlanner.runEmittedEvents(
463+
planned.emittedEvents as ReadonlyArray<Machine.EmitOf<Emits>>,
464+
liveRuntime
465+
)
466+
467+
if (planned.done) {
468+
terminal = { output: planned.output }
469+
yield* stopAllInvokes(Exit.succeed(planned.output))
470+
} else {
471+
if (changed) {
472+
for (const [path, entryEvent] of entryEvents) {
473+
yield* startInvokes(planned.next, [path], entryEvent)
474+
}
440475
}
441476
}
442477
}
478+
479+
if (terminal === undefined) {
480+
pendingEvent = yield* (pollEvent ??= Queue.poll(mailbox))
481+
if (Option.isNone(pendingEvent)) {
482+
configuration = undefined
483+
pollEvent = undefined
484+
}
485+
}
443486
}),
444487
step: () => undefined
445488
})

0 commit comments

Comments
 (0)