Skip to content

Commit 9b492e3

Browse files
authored
Merge pull request #51 from cardmagic/feat/effect-recovery
Coordinate abandoned effect recovery
2 parents 317e636 + 9de7c2c commit 9b492e3

33 files changed

Lines changed: 1704 additions & 55 deletions

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## 0.15.0 - 2026-09-15
4+
5+
- Report transient process-heartbeat errors and keep retrying while effects run,
6+
so one failed update cannot permanently disable owner freshness maintenance.
7+
- Return a stable `EffectHandle` from every `emit`. Overrides/wrappers must
8+
return the handle; code expecting `void`/`undefined` needs migration.
9+
- Add SQL effect recovery coordination with `onRecovery`, optional `onStatus`,
10+
staged `requestEffectRecovery`, and an extending `recoveryTimeoutMilliseconds`.
11+
Retirement and durable callbacks share the claim-locking transaction. Export
12+
typed outcome constants and callback envelopes. Install schema migration 9
13+
before running this version. Cloudflare returns emit handles and rejects the
14+
unsupported process-heartbeat recovery options before commit.
15+
316
## 0.14.9 - 2026-09-14
417

518
- Export effect failure/success payload types and `SerializedError` from the

‎docs/api.md‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,145 @@ row per item. It also cannot strand an entry when the runtime coalesces an
162162
occurrence. Prefer it for a large queue of interchangeable items. Prefer `key`
163163
when one item needs an alarm that you can move on its own.
164164

165+
### Recovering abandoned effects
166+
167+
`emit` returns an `EffectHandle` (`{ id: string }`) on every successful call. Save
168+
it in actor state to identify that exact persisted effect. The handle, state,
169+
effect, and callback bindings commit together; a rejected turn persists none of
170+
them. Existing callers can ignore the handle. Overrides and wrappers that
171+
previously returned `void` must return `super.emit(...)`; explicit
172+
`return this.emit(...)` and code expecting `undefined` need updating.
173+
174+
`onRecovery` opts a SQL effect into automatic retirement when its processing
175+
owner stops heartbeating. `onStatus` is independent and optional: it only receives
176+
responses to `requestEffectRecovery(handle)`, which requires both bindings.
177+
Register both callbacks in `emit`; requests cannot rebind them. Requests stage an
178+
intent in the current actor's fenced commit and perform no synchronous database
179+
lookup inside the actor method. Only the originating instance can use its handle.
180+
181+
`recoveryTimeoutMilliseconds` is an optional positive safe integer requiring
182+
`onRecovery`. The effective freshness window is the greater of that persisted
183+
override and the runtime's current `processAliveThresholdMilliseconds` (default
184+
60,000). It can extend the window, never shorten it. It measures time since the
185+
owner's database heartbeat, not effect duration or progress. An owner that keeps
186+
heartbeating protects its effect indefinitely.
187+
188+
Heartbeat update errors emit `solid_objects.process.heartbeat_failed` and retry
189+
at the configured interval without consuming effect attempts. An outage lasting
190+
beyond the freshness window can still permit recovery; this does not cancel
191+
external work or extend the timeout.
192+
193+
`EffectRetiredPayload<Arguments>` is the `onRecovery` envelope: `effectId`, original
194+
`arguments`, and `outcome: EffectRecoveryOutcome.Retired`. No outcome guard is
195+
needed in that callback. `EffectRecoveryPayload<Arguments, Result>` is the
196+
discriminated union received by `onStatus`. `EffectRecoveryOutcome` is a frozen
197+
constant object and a derived string-union type, exported from root and core.
198+
199+
| Constant | Outcome | Meaning |
200+
| ---------------- | ------------------ | ------------------------------------------------------------------ |
201+
| `Retired` | `"retired"` | This check retired the abandoned processing effect. |
202+
| `Deferred` | `"deferred"` | Owner is fresh; preserve its claim and attempts. |
203+
| `Pending` | `"pending"` | Initial execution or retry remains with the scheduler. |
204+
| `Completed` | `"completed"` | Includes original arguments and recorded result, including `null`. |
205+
| `Dead` | `"dead"` | Preserve the existing terminal failure and failure callback. |
206+
| `AlreadyRetired` | `"alreadyRetired"` | An earlier decision retired it; no new recovery notification. |
207+
| `Missing` | `"missing"` | Owned routing metadata remains but the effect was pruned. |
208+
209+
Every outcome includes `effectId`. Retired and completed require original
210+
arguments; other outcomes may include retained arguments. Only completed has a
211+
successful `result`. Database errors propagate as errors, never as missing or
212+
abandoned outcomes. Unknown, foreign, and expired handles fail without exposing
213+
another actor's effects or recreating a destroyed actor.
214+
215+
Automatic retirement sends only `onRecovery`. A winning explicit check enqueues
216+
`onRecovery` first and its separate `onStatus` response second in one transaction.
217+
Only `onRecovery` should emit replacement work. A completed status can repair an
218+
outcome notification using the same guarded helper as `onSuccess`:
219+
220+
```ts
221+
import {
222+
Actor,
223+
EffectRecoveryOutcome,
224+
type EffectHandle,
225+
type EffectRetiredPayload,
226+
type EffectRecoveryPayload,
227+
type EffectSuccessPayload,
228+
type JsonValue,
229+
} from "solid-objects"
230+
231+
type ReportArguments = { revision: number }
232+
type ReportResult = { artifactKey: string }
233+
234+
class ReportExport extends Actor {
235+
static override readonly actorType = "ReportExport"
236+
revision = 0
237+
exportEffect: EffectHandle | null = null
238+
artifactKey = ""
239+
appliedEffectId: string | null = null
240+
241+
start(): void {
242+
this.exportEffect = this.emit("build_report", {
243+
arguments: { revision: ++this.revision },
244+
onSuccess: "exportFinished",
245+
onFailure: "exportFailed",
246+
onRecovery: "recoverExport",
247+
onStatus: "inspectExport",
248+
recoveryTimeoutMilliseconds: 120_000,
249+
})
250+
this.schedule({ at: new Date(Date.now() + 30_000), key: "export-watchdog" }).watchdog()
251+
}
252+
253+
watchdog(): void {
254+
if (this.exportEffect) this.requestEffectRecovery(this.exportEffect)
255+
}
256+
257+
recoverExport(payload: EffectRetiredPayload<ReportArguments>): void {
258+
if (payload.effectId !== this.exportEffect?.id || payload.arguments.revision !== this.revision)
259+
return
260+
this.start()
261+
}
262+
263+
exportFinished(payload: EffectSuccessPayload<ReportArguments, ReportResult>): void {
264+
this.applyExportResult(payload)
265+
}
266+
267+
exportFailed(_payload: JsonValue): void {}
268+
269+
inspectExport(payload: EffectRecoveryPayload<ReportArguments, ReportResult>): void {
270+
if (payload.effectId !== this.exportEffect?.id) return
271+
if (payload.outcome === EffectRecoveryOutcome.Completed) this.applyExportResult(payload)
272+
if (
273+
payload.outcome === EffectRecoveryOutcome.Deferred ||
274+
payload.outcome === EffectRecoveryOutcome.Pending
275+
) {
276+
this.schedule({ at: new Date(Date.now() + 30_000), key: "export-watchdog" }).watchdog()
277+
}
278+
}
279+
280+
private applyExportResult(payload: EffectSuccessPayload<ReportArguments, ReportResult>): void {
281+
if (payload.effectId !== this.exportEffect?.id || payload.arguments.revision !== this.revision)
282+
return
283+
if (this.appliedEffectId === payload.effectId) return
284+
this.artifactKey = payload.result.artifactKey
285+
this.appliedEffectId = payload.effectId
286+
}
287+
}
288+
```
289+
290+
Routing metadata remains until the originating instance is destroyed or pruned;
291+
it survives effect/message pruning but does not pin the instance. Checks after
292+
that boundary fail. Callback delivery and idempotency follow durable mailbox
293+
retention. Retirement survives a crash before callback delivery.
294+
295+
The Durable Objects backend returns ordinary emit handles using its outbox ID,
296+
but rejects recovery callbacks, timeouts, and recovery intents before committing
297+
the actor turn: it has no shared SQL process-heartbeat registry. See
298+
[effect recovery coordination](effect-recovery.md) for transaction and lock order.
299+
300+
**External actions still require idempotency.** Retirement fences library state;
301+
it does not cancel the previous JavaScript handler or prove its remote request
302+
stopped. It does not provide exactly-once external execution.
303+
165304
### Typing your onFailure handler
166305

167306
An effect callback is an ordinary actor operation. Its payload always includes

‎docs/effect-recovery.md‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Effect recovery coordination
2+
3+
Install the additive schema migration and upgrade all effect workers and process
4+
cleanup roles before emitting recovery-enabled effects. Older runtime versions
5+
do not honor the persisted recovery bindings or the new lock protocol.
6+
7+
The single `emit` API allocates a stable effect ID at staging and returns its
8+
JSON handle. The fenced actor commit persists the effect, actor state, and
9+
optional recovery/status binding together. `requestEffectRecovery` stages a
10+
check on that same transaction connection. It never opens another transaction
11+
while an application commit action holds locks.
12+
13+
Automatic polling checks at most `claimScanLimit` stale candidates per pass,
14+
prefiltering with database time, owner heartbeat, and the effective timeout. It
15+
does not lock fresh owners or their actors, even when the global liveness floor
16+
has elapsed but an effect's extended grace has not. Each candidate gets one
17+
independent transaction; unlocked candidate reads remain hints only. Lock order is origin
18+
instance, effects ordered by ID, recovery bindings ordered by effect ID, then
19+
current owner processes ordered by ID. Explicit batches acquire all effect and
20+
binding locks before any process locks. Completion/failure lock the instance
21+
before the effect. Pending claims lock the effect and never subsequently lock
22+
the instance. Mailbox insertion reuses the origin instance lock.
23+
24+
After waiting for these locks, the decision uses current ownership, a locked
25+
heartbeat, database wall time, and the maximum of the current runtime threshold
26+
and the persisted per-effect override. Missing owners are stale; query errors
27+
are errors. Process shutdown preserves heartbeat evidence and opted-in claims.
28+
Process pruning excludes effect owners; later polling revisits stopped owners
29+
until each effect's individual grace expires. Ordinary effects and pending
30+
retries retain their scheduler behavior.
31+
32+
Retirement records `retired_at_ms` in `effect_recoveries`, clears the claim, and
33+
uses the existing terminal `completed` effect storage state. The durable binding
34+
distinguishes retirement from successful completion and is checked first by all
35+
recovery observations. No success callback is generated. This avoids rewriting
36+
existing status constraints across PostgreSQL, MySQL, and SQLite. Internal
37+
effect-table status alone is not the recovery outcome. Late completion/failure
38+
must still match a processing claim, which retirement removes.
39+
40+
The terminal transition and `effect:<id>:recovery` mailbox insertion are atomic.
41+
A winning explicit check additionally enqueues its separate status response,
42+
after recovery, keyed by `effect:<id>:check:<internal-request-id>`. Failure of
43+
either insertion rolls the transaction back. Multiple checks share one durable
44+
retirement, with one response per request. A crash after commit cannot lose the
45+
recovery callback. Successful retirement wakes actor workers after commit.
46+
Wake-up failures are logged without changing the committed decision; mailbox
47+
polling provides delivery.
48+
49+
Bindings belong to the exact originating instance, survive effect/message
50+
pruning, and cascade when the instance is deleted. They neither pin instances nor
51+
authorize cross-actor access. Within that retention lifetime a pruned effect can
52+
report missing or already retired. Outside it, checks fail. Message idempotency
53+
has normal mailbox retention; applications cannot supply internal request IDs.
54+
55+
See [the watchdog example](api.md#recovering-abandoned-effects). Success and
56+
completed-status repair use one application guard; status never owns replacement.
57+
External systems still require idempotency across retries and replacement
58+
generations. Stale heartbeat evidence grants library recovery permission; it
59+
does not prove the previous handler or remote operation stopped.

‎docs/parity.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,21 @@ Rails generators, Active Record models/controllers, Turbo rendering, and
217217
Action Cable are not copied into this package. The Rack dashboard is represented
218218
by the framework-neutral Fetch and Node adapter, renderer callbacks, and the
219219
same authorization and CSRF boundaries.
220+
221+
## Effect recovery
222+
223+
Both runtimes maintain heartbeats during effect execution and retry failed
224+
updates at the configured interval, reporting `process.heartbeat_failed`.
225+
226+
| Capability | Status | Contract |
227+
| ----------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
228+
| Abandoned SQL effect recovery | Native | Stable emit handles, automatic retirement, staged status checks, durable callbacks, and extending heartbeat grace in both languages. |
229+
| Shared process-heartbeat recovery on Cloudflare | Not applicable | Durable Objects has no shared SQL process registry; recovery options and intents fail before commit. |
230+
231+
SQL effect recovery uses the same contract in Ruby and JavaScript: one `emit`
232+
returns a stable handle; `onRecovery`/`on_recovery` opts into atomic retirement
233+
and a durable callback; optional `onStatus`/`on_status` answers explicit staged
234+
checks. Per-effect recovery timeouts extend the runtime heartbeat threshold
235+
(milliseconds in JS, seconds in Ruby). Cloudflare returns emit handles but rejects
236+
process-heartbeat recovery options and intents before commit. See
237+
[the transaction protocol](effect-recovery.md).

‎examples/failure-recovery/actor.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
import { appendFile, access, writeFile } from "node:fs/promises"
22
import { join } from "node:path"
3-
import { Actor } from "solid-objects"
3+
import { Actor, type EffectHandle, type EffectRetiredPayload } from "solid-objects"
4+
5+
export class RecoverableReport extends Actor {
6+
static override readonly actorType = "RecoverableReport"
7+
exportEffect: EffectHandle | null = null
8+
recoveryCount = 0
9+
10+
start(): void {
11+
this.exportEffect = this.emit("build_report", {
12+
arguments: { revision: 1 },
13+
onRecovery: "recoverExport",
14+
})
15+
}
16+
17+
recoverExport(payload: EffectRetiredPayload<{ revision: number }>): void {
18+
if (payload.effectId !== this.exportEffect?.id || payload.arguments.revision !== 1) return
19+
this.recoveryCount += 1
20+
}
21+
}
422

523
export class RecoveryCounter extends Actor {
624
static override readonly actorType = "RecoveryCounter"

‎examples/failure-recovery/demo.ts‎

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"
77
import { fork, type ChildProcess } from "node:child_process"
88
import { createRuntime, type ActorReference, type MessageReference } from "solid-objects"
99
import { sqlite } from "solid-objects/database/sqlite"
10-
import { RecoveryCounter } from "./actor.ts"
10+
import { RecoverableReport, RecoveryCounter } from "./actor.ts"
1111
import {
1212
assertSerializedExecution,
1313
parseSerializationEvent,
@@ -46,11 +46,15 @@ const runtime = createRuntime({
4646

4747
try {
4848
runtime.register(RecoveryCounter)
49+
runtime.register(RecoverableReport)
4950
await runtime.install()
5051
const serialization = await proveSerialization()
5152
const crash = await proveCrashRecovery()
5253
const fencing = await proveFencing()
53-
process.stdout.write(`${JSON.stringify({ serialization, crash, fencing }, null, 2)}\n`)
54+
const effectRecovery = await proveEffectRecovery()
55+
process.stdout.write(
56+
`${JSON.stringify({ serialization, crash, fencing, effectRecovery }, null, 2)}\n`,
57+
)
5458
} finally {
5559
await runtime.close()
5660
await rm(directory, { recursive: true })
@@ -140,15 +144,51 @@ async function recoveryResult(options: {
140144
return { attempts, finalState: snapshot.count, repeatedEffects: effects.length }
141145
}
142146

143-
function spawnWorker(): {
147+
async function proveEffectRecovery(): Promise<{ recoveryCallbacks: number }> {
148+
const reference = runtime.ref(RecoverableReport, "report")
149+
await reference.start()
150+
await runtime.repository.registerProcess("abandoned-effect-owner", "effect")
151+
const effect = await runtime.repository.claimEffect("abandoned-effect-owner")
152+
assert.ok(effect)
153+
await runtime.settings.database.connection((connection) =>
154+
connection.run(
155+
`UPDATE ${runtime.repository.table("processes")} SET heartbeat_at_ms = 0 WHERE id = ?`,
156+
["abandoned-effect-owner"],
157+
),
158+
)
159+
const retiringWorker = spawnWorker({ mode: "retire-effects" })
160+
const stopped = retiringWorker.finished.catch(() => undefined)
161+
try {
162+
await Promise.race([
163+
retiringWorker.waitFor((message) => message.event === "effects.retired"),
164+
retiringWorker.finished.then(() => {
165+
throw new Error("worker exited before retirement")
166+
}),
167+
])
168+
} finally {
169+
retiringWorker.child.kill("SIGKILL")
170+
await stopped
171+
}
172+
assert.equal((await reference.snapshot()).recoveryCount, 0)
173+
await spawnWorker().finished
174+
assert.equal((await reference.snapshot()).recoveryCount, 1)
175+
assert.equal(await runtime.repository.claimEffect("abandoned-effect-owner"), undefined)
176+
return { recoveryCallbacks: 1 }
177+
}
178+
179+
function spawnWorker(options: { mode?: "retire-effects" } = {}): {
144180
child: ChildProcess
145181
finished: Promise<void>
146182
waitFor(predicate: (message: WorkerMessage) => boolean): Promise<WorkerMessage>
147183
} {
148-
const child = fork(fileURLToPath(new URL("./worker.ts", import.meta.url)), [databasePath], {
149-
cwd: fileURLToPath(new URL("../..", import.meta.url)),
150-
stdio: ["ignore", "pipe", "pipe", "ipc"],
151-
})
184+
const child = fork(
185+
fileURLToPath(new URL("./worker.ts", import.meta.url)),
186+
[databasePath, ...(options.mode ? [options.mode] : [])],
187+
{
188+
cwd: fileURLToPath(new URL("../..", import.meta.url)),
189+
stdio: ["ignore", "pipe", "pipe", "ipc"],
190+
},
191+
)
152192
const messages: WorkerMessage[] = []
153193
const listeners = new Set<(message: WorkerMessage) => void>()
154194
let stderr = ""

‎examples/failure-recovery/worker.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createRuntime } from "solid-objects"
22
import { sqlite } from "solid-objects/database/sqlite"
3-
import { RecoveryCounter } from "./actor.ts"
3+
import { RecoverableReport, RecoveryCounter } from "./actor.ts"
44

55
const databasePath = requiredArgument(2)
66
const runtime = createRuntime({
@@ -24,7 +24,15 @@ const runtime = createRuntime({
2424
})
2525

2626
runtime.register(RecoveryCounter)
27+
runtime.register(RecoverableReport)
2728
await runtime.install()
29+
if (process.argv[3] === "retire-effects") {
30+
await runtime.repository.cleanupStaleProcesses()
31+
process.send?.({ event: "effects.retired" })
32+
await new Promise<void>(() => {
33+
process.on("message", () => {})
34+
})
35+
}
2836
const worker = runtime.worker()
2937

3038
try {

‎package.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "solid-objects",
3-
"version": "0.14.9",
3+
"version": "0.15.0",
44
"description": "Race-free realtime state per application identity, backed by your SQL database",
55
"type": "module",
66
"license": "MIT",
@@ -115,8 +115,8 @@
115115
"test": "vitest run",
116116
"test:browser": "pnpm run build && playwright test",
117117
"test:coverage": "vitest run --coverage",
118-
"test:postgresql": "vitest run test/postgresql.test.ts",
119-
"test:mysql": "vitest run test/mysql.test.ts",
118+
"test:postgresql": "vitest run test/postgresql.test.ts test/effect-recovery.test.ts",
119+
"test:mysql": "vitest run test/mysql.test.ts test/effect-recovery.test.ts",
120120
"test:package": "node scripts/release-artifact-smoke.mjs",
121121
"test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
122122
"test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",

0 commit comments

Comments
 (0)