solid-objects 0.14.9 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/actor.d.ts +16 -3
  3. package/dist/actor.d.ts.map +1 -1
  4. package/dist/actor.js +31 -1
  5. package/dist/actor.js.map +1 -1
  6. package/dist/cloudflare/engine.d.ts.map +1 -1
  7. package/dist/cloudflare/engine.js +7 -1
  8. package/dist/cloudflare/engine.js.map +1 -1
  9. package/dist/core.d.ts +1 -0
  10. package/dist/core.d.ts.map +1 -1
  11. package/dist/core.js +1 -0
  12. package/dist/core.js.map +1 -1
  13. package/dist/doctor.d.ts.map +1 -1
  14. package/dist/doctor.js +10 -2
  15. package/dist/doctor.js.map +1 -1
  16. package/dist/effect-recovery-coordinator.d.ts +29 -0
  17. package/dist/effect-recovery-coordinator.d.ts.map +1 -0
  18. package/dist/effect-recovery-coordinator.js +152 -0
  19. package/dist/effect-recovery-coordinator.js.map +1 -0
  20. package/dist/effect-recovery.d.ts +27 -0
  21. package/dist/effect-recovery.d.ts.map +1 -0
  22. package/dist/effect-recovery.js +10 -0
  23. package/dist/effect-recovery.js.map +1 -0
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/platform/uuid.d.ts.map +1 -1
  29. package/dist/platform/uuid.js +2 -1
  30. package/dist/platform/uuid.js.map +1 -1
  31. package/dist/repository.d.ts +1 -0
  32. package/dist/repository.d.ts.map +1 -1
  33. package/dist/repository.js +45 -5
  34. package/dist/repository.js.map +1 -1
  35. package/dist/runtime.d.ts +0 -1
  36. package/dist/runtime.d.ts.map +1 -1
  37. package/dist/runtime.js +3 -16
  38. package/dist/runtime.js.map +1 -1
  39. package/dist/schema.d.ts.map +1 -1
  40. package/dist/schema.js +26 -1
  41. package/dist/schema.js.map +1 -1
  42. package/dist/types.d.ts +3 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/wake-up-notification.d.ts +8 -0
  47. package/dist/wake-up-notification.d.ts.map +1 -0
  48. package/dist/wake-up-notification.js +12 -0
  49. package/dist/wake-up-notification.js.map +1 -0
  50. package/dist/worker.js +11 -1
  51. package/dist/worker.js.map +1 -1
  52. package/docs/api.md +139 -0
  53. package/docs/effect-recovery.md +59 -0
  54. package/docs/parity.md +18 -0
  55. package/examples/failure-recovery/actor.ts +19 -1
  56. package/examples/failure-recovery/demo.ts +47 -7
  57. package/examples/failure-recovery/worker.ts +9 -1
  58. package/package.json +3 -3
package/docs/api.md CHANGED
@@ -162,6 +162,145 @@ row per item. It also cannot strand an entry when the runtime coalesces an
162
162
  occurrence. Prefer it for a large queue of interchangeable items. Prefer `key`
163
163
  when one item needs an alarm that you can move on its own.
164
164
 
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
+
165
304
  ### Typing your onFailure handler
166
305
 
167
306
  An effect callback is an ordinary actor operation. Its payload always includes
@@ -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.
package/docs/parity.md CHANGED
@@ -217,3 +217,21 @@ Rails generators, Active Record models/controllers, Turbo rendering, and
217
217
  Action Cable are not copied into this package. The Rack dashboard is represented
218
218
  by the framework-neutral Fetch and Node adapter, renderer callbacks, and the
219
219
  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).
@@ -1,6 +1,24 @@
1
1
  import { appendFile, access, writeFile } from "node:fs/promises"
2
2
  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
+ }
4
22
 
5
23
  export class RecoveryCounter extends Actor {
6
24
  static override readonly actorType = "RecoveryCounter"
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"
7
7
  import { fork, type ChildProcess } from "node:child_process"
8
8
  import { createRuntime, type ActorReference, type MessageReference } from "solid-objects"
9
9
  import { sqlite } from "solid-objects/database/sqlite"
10
- import { RecoveryCounter } from "./actor.ts"
10
+ import { RecoverableReport, RecoveryCounter } from "./actor.ts"
11
11
  import {
12
12
  assertSerializedExecution,
13
13
  parseSerializationEvent,
@@ -46,11 +46,15 @@ const runtime = createRuntime({
46
46
 
47
47
  try {
48
48
  runtime.register(RecoveryCounter)
49
+ runtime.register(RecoverableReport)
49
50
  await runtime.install()
50
51
  const serialization = await proveSerialization()
51
52
  const crash = await proveCrashRecovery()
52
53
  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
+ )
54
58
  } finally {
55
59
  await runtime.close()
56
60
  await rm(directory, { recursive: true })
@@ -140,15 +144,51 @@ async function recoveryResult(options: {
140
144
  return { attempts, finalState: snapshot.count, repeatedEffects: effects.length }
141
145
  }
142
146
 
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" } = {}): {
144
180
  child: ChildProcess
145
181
  finished: Promise<void>
146
182
  waitFor(predicate: (message: WorkerMessage) => boolean): Promise<WorkerMessage>
147
183
  } {
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
+ )
152
192
  const messages: WorkerMessage[] = []
153
193
  const listeners = new Set<(message: WorkerMessage) => void>()
154
194
  let stderr = ""
@@ -1,6 +1,6 @@
1
1
  import { createRuntime } from "solid-objects"
2
2
  import { sqlite } from "solid-objects/database/sqlite"
3
- import { RecoveryCounter } from "./actor.ts"
3
+ import { RecoverableReport, RecoveryCounter } from "./actor.ts"
4
4
 
5
5
  const databasePath = requiredArgument(2)
6
6
  const runtime = createRuntime({
@@ -24,7 +24,15 @@ const runtime = createRuntime({
24
24
  })
25
25
 
26
26
  runtime.register(RecoveryCounter)
27
+ runtime.register(RecoverableReport)
27
28
  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
+ }
28
36
  const worker = runtime.worker()
29
37
 
30
38
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-objects",
3
- "version": "0.14.9",
3
+ "version": "0.15.0",
4
4
  "description": "Race-free realtime state per application identity, backed by your SQL database",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -115,8 +115,8 @@
115
115
  "test": "vitest run",
116
116
  "test:browser": "pnpm run build && playwright test",
117
117
  "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",
120
120
  "test:package": "node scripts/release-artifact-smoke.mjs",
121
121
  "test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
122
122
  "test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",