Skip to content

Commit ac9805f

Browse files
Use compact compiled mailboxes
1 parent d3dc770 commit ac9805f

4 files changed

Lines changed: 114 additions & 22 deletions

File tree

.changeset/lean-mailboxes-drain.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Use a compact FIFO mailbox for on-demand compiled statecharts while retaining
6+
Effect Queue for persistent custom process logic. This reduces idle heap for
7+
machines and invoked families while preserving FIFO delivery, terminal send
8+
rejection, and wake-up behavior.

src/internal/machineProcess.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ const makeProcessLogic: <
153153
drain: (context: internalRuntime.ProcessContext<Machine.Snapshot<States>, Machine.EventOf<Events>>) =>
154154
internalRuntime.provideMachineRuntime(
155155
Effect.gen(function*() {
156-
const { mailbox, state, setState } = context
156+
const poll = context.poll ?? Queue.poll(context.mailbox!)
157+
const { state, setState } = context
157158
let current = yield* state
158159
if (internalPlanner.isFinalState(machine, current)) {
159160
return Option.some(
@@ -380,7 +381,7 @@ const makeProcessLogic: <
380381
}
381382

382383
while (true) {
383-
const pending = yield* Queue.poll(mailbox)
384+
const pending = yield* poll
384385
if (Option.isNone(pending)) {
385386
return Option.none<Output>()
386387
}
@@ -447,7 +448,8 @@ const makeProcessLogic: <
447448
run: (context) =>
448449
internalRuntime.provideMachineRuntime(
449450
Effect.gen(function*() {
450-
const { mailbox, receive, state, setState } = context
451+
const poll = context.poll ?? Queue.poll(context.mailbox!)
452+
const { receive, state, setState } = context
451453
let terminal: { readonly output: Output } | undefined
452454

453455
let current = yield* state
@@ -506,7 +508,7 @@ const makeProcessLogic: <
506508
}
507509

508510
if (terminal === undefined) {
509-
pendingEvent = yield* (pollEvent ??= Queue.poll(mailbox))
511+
pendingEvent = yield* (pollEvent ??= poll)
510512
if (Option.isNone(pendingEvent)) {
511513
configuration = undefined
512514
pollEvent = undefined
@@ -785,7 +787,7 @@ const makeProcessLogic: <
785787
}
786788

787789
if (terminal === undefined) {
788-
pendingEvent = yield* (pollEvent ??= Queue.poll(mailbox))
790+
pendingEvent = yield* (pollEvent ??= poll)
789791
if (Option.isNone(pendingEvent)) {
790792
configuration = undefined
791793
pollEvent = undefined

src/internal/machineRuntime.ts

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,48 @@ export interface ProcessScope<Event> {
157157

158158
export interface ProcessContext<State, Event> extends ProcessScope<Event> {
159159
readonly receive: Effect.Effect<Event>
160-
readonly mailbox: Queue.Dequeue<Event>
160+
/** @internal */
161+
readonly mailbox?: Queue.Dequeue<Event>
162+
/** @internal */
163+
readonly poll?: Effect.Effect<Option.Option<Event>>
161164
readonly state: Effect.Effect<State>
162165
readonly setState: (state: State) => Effect.Effect<void>
163166
readonly updateState: <E, R>(
164167
f: (state: State) => Effect.Effect<State, E, R>
165168
) => Effect.Effect<void, E, R>
166169
}
167170

171+
interface CompactProcessMailbox<Event> {
172+
items: Array<Event> | undefined
173+
index: number
174+
closed: boolean
175+
}
176+
177+
const offerCompactMailbox = <Event>(mailbox: CompactProcessMailbox<Event>, event: Event): void => {
178+
const items = mailbox.items ?? []
179+
mailbox.items = items
180+
items.push(event)
181+
}
182+
183+
const pollCompactMailbox = <Event>(mailbox: CompactProcessMailbox<Event>): Option.Option<Event> => {
184+
if (mailbox.items === undefined) {
185+
return Option.none()
186+
}
187+
const event = mailbox.items[mailbox.index]!
188+
mailbox.index += 1
189+
if (mailbox.index === mailbox.items.length) {
190+
mailbox.items = undefined
191+
mailbox.index = 0
192+
}
193+
return Option.some(event)
194+
}
195+
196+
const closeCompactMailbox = (mailbox: CompactProcessMailbox<unknown>): void => {
197+
mailbox.closed = true
198+
mailbox.items = undefined
199+
mailbox.index = 0
200+
}
201+
168202
export interface ProcessLogic<
169203
State,
170204
Event,
@@ -1145,11 +1179,24 @@ const startCompiledInternal: <
11451179

11461180
const sessionId = yield* options.runtime.nextSessionId
11471181
const id = options.id ?? sessionId
1148-
const queue = yield* Queue.unbounded<Event>()
1182+
const onDemand = logic.drain !== undefined
1183+
const queue = onDemand ? undefined : yield* Queue.unbounded<Event>()
1184+
// An on-demand drain never blocks on mailbox input: send schedules its owner
1185+
// whenever the FIFO becomes non-empty. Keep persistent custom processes on
1186+
// Queue, but avoid retaining Queue's waiting/backpressure machinery for the
1187+
// compiled protocol that only needs synchronous offer and poll operations.
1188+
const compactMailbox: CompactProcessMailbox<Event> | undefined = onDemand
1189+
? { items: undefined, index: 0, closed: false }
1190+
: undefined
1191+
const poll = compactMailbox === undefined
1192+
? Queue.poll(queue!)
1193+
: Effect.sync(() => pollCompactMailbox(compactMailbox))
1194+
const shutdownMailbox = compactMailbox === undefined
1195+
? Queue.shutdown(queue!)
1196+
: Effect.sync(() => closeCompactMailbox(compactMailbox))
11491197
const termination = yield* Deferred.make<ProcessTermination>()
11501198
const done = yield* Deferred.make<Output, Error | StoppedError>()
11511199
const awaitCompletion = Deferred.await(done).pipe(Effect.exit, Effect.asVoid)
1152-
const onDemand = logic.drain !== undefined
11531200
const drainServices = onDemand ? yield* Effect.context<Requirements>() : undefined
11541201
let worker: Fiber.Fiber<any, never> | undefined
11551202
let draining = false
@@ -1158,10 +1205,12 @@ const startCompiledInternal: <
11581205
let terminationRequested = false
11591206
let requestRuntimeTermination = (requested: ProcessTermination): Effect.Effect<boolean> =>
11601207
Deferred.succeed(termination, requested)
1161-
let sendEvent = (event: Event): Effect.Effect<void, StoppedError> =>
1162-
Queue.offer(queue, event).pipe(
1163-
Effect.flatMap((accepted) => accepted ? Effect.void : Effect.fail(new StoppedError()))
1164-
)
1208+
let sendEvent = compactMailbox !== undefined
1209+
? (event: Event): Effect.Effect<void, StoppedError> => Effect.sync(() => offerCompactMailbox(compactMailbox, event))
1210+
: (event: Event): Effect.Effect<void, StoppedError> =>
1211+
Queue.offer(queue!, event).pipe(
1212+
Effect.flatMap((accepted) => accepted ? Effect.void : Effect.fail(new StoppedError()))
1213+
)
11651214
let settleRequestedTermination = (_requested: ProcessTermination): Effect.Effect<void> => Effect.void
11661215
const interruptWorker: Effect.Effect<void> = Effect.suspend(() =>
11671216
worker === undefined
@@ -1410,7 +1459,7 @@ const startCompiledInternal: <
14101459
Effect.asVoid
14111460
)
14121461
return Effect.uninterruptible(
1413-
Queue.shutdown(queue).pipe(
1462+
shutdownMailbox.pipe(
14141463
Effect.andThen(closeChildren(exit)),
14151464
Effect.andThen(setAndPublishSnapshot(snapshot)),
14161465
Effect.andThen(notifyOutcome),
@@ -1536,8 +1585,8 @@ const startCompiledInternal: <
15361585

15371586
const context: ProcessContext<State, Event> = {
15381587
...scope,
1539-
receive: Queue.take(queue),
1540-
mailbox: queue,
1588+
receive: queue === undefined ? Effect.never : Queue.take(queue),
1589+
poll,
15411590
state: getCurrent.pipe(Effect.map((current) => current.snapshot.state)),
15421591
setState: setActiveState,
15431592
updateState: (f) =>
@@ -1683,13 +1732,11 @@ const startCompiledInternal: <
16831732
sendEvent = (event) =>
16841733
Effect.uninterruptible(
16851734
Effect.suspend(() => {
1686-
if (!Queue.offerUnsafe(queue, event)) {
1735+
if (compactMailbox!.closed || terminationRequested) {
16871736
return Effect.fail(new StoppedError())
16881737
}
1738+
offerCompactMailbox(compactMailbox!, event)
16891739
offerRevision += 1
1690-
if (terminationRequested) {
1691-
return Effect.fail(new StoppedError())
1692-
}
16931740
if (draining) {
16941741
return Effect.void
16951742
}

test/MachineProcessLifecycle.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,44 @@
11
import { assert, describe, it } from "@effect/vitest"
2-
import { Cause, Deferred, Effect, Exit, Fiber, Option, Queue, Ref, Stream } from "effect"
2+
import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Stream } from "effect"
33
import { Machine } from "../src/index.js"
44
import * as MachineRuntime from "../src/internal/machineRuntime.js"
55

66
describe("machine process lifecycle", () => {
7+
it.effect("preserves FIFO order while draining a compact compiled mailbox", () =>
8+
Effect.gen(function*() {
9+
type Event =
10+
| { readonly _tag: "Value"; readonly value: number }
11+
| { readonly _tag: "Done" }
12+
const observed: Array<number> = []
13+
const ref = yield* MachineRuntime.startProcess<undefined, Event, never, never, ReadonlyArray<number>>({
14+
[MachineRuntime.childlessProcess]: true,
15+
[MachineRuntime.compiledProcess]: true,
16+
initial: () => Effect.succeed(undefined),
17+
run: () => Effect.never,
18+
drain: (context) =>
19+
Effect.gen(function*() {
20+
while (true) {
21+
const event = yield* context.poll!
22+
if (Option.isNone(event)) {
23+
return Option.none()
24+
}
25+
if (event.value._tag === "Done") {
26+
return Option.some(observed)
27+
}
28+
observed.push(event.value.value)
29+
}
30+
})
31+
})
32+
33+
for (let value = 0; value < 1_000; value += 1) {
34+
yield* ref.send({ _tag: "Value", value })
35+
}
36+
yield* ref.send({ _tag: "Done" })
37+
38+
assert.deepStrictEqual(yield* ref.join, Array.from({ length: 1_000 }, (_, value) => value))
39+
assert.instanceOf(yield* Effect.flip(ref.send({ _tag: "Done" })), Machine.StoppedError)
40+
}))
41+
742
it.effect("wakes an on-demand compiled process across consecutive idle periods", () =>
843
Effect.gen(function*() {
944
type Event = {
@@ -18,7 +53,7 @@ describe("machine process lifecycle", () => {
1853
drain: (context) =>
1954
Effect.gen(function*() {
2055
while (true) {
21-
const event = yield* Queue.poll(context.mailbox)
56+
const event = yield* context.poll!
2257
if (Option.isNone(event)) {
2358
return Option.none()
2459
}
@@ -296,7 +331,7 @@ describe("machine process lifecycle", () => {
296331
run: () => Effect.never,
297332
drain: (context) =>
298333
Effect.gen(function*() {
299-
const event = yield* Queue.poll(context.mailbox)
334+
const event = yield* context.poll!
300335
if (Option.isNone(event)) {
301336
return Option.none()
302337
}

0 commit comments

Comments
 (0)