solid-objects 0.14.8 → 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.
- package/CHANGELOG.md +25 -0
- package/dist/actor.d.ts +25 -9
- package/dist/actor.d.ts.map +1 -1
- package/dist/actor.js +31 -2
- package/dist/actor.js.map +1 -1
- package/dist/cloudflare/engine.d.ts.map +1 -1
- package/dist/cloudflare/engine.js +12 -7
- package/dist/cloudflare/engine.js.map +1 -1
- package/dist/core.d.ts +1 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +1 -0
- package/dist/core.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +10 -2
- package/dist/doctor.js.map +1 -1
- package/dist/effect-recovery-coordinator.d.ts +29 -0
- package/dist/effect-recovery-coordinator.d.ts.map +1 -0
- package/dist/effect-recovery-coordinator.js +152 -0
- package/dist/effect-recovery-coordinator.js.map +1 -0
- package/dist/effect-recovery.d.ts +27 -0
- package/dist/effect-recovery.d.ts.map +1 -0
- package/dist/effect-recovery.js +10 -0
- package/dist/effect-recovery.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/platform/uuid.d.ts.map +1 -1
- package/dist/platform/uuid.js +2 -1
- package/dist/platform/uuid.js.map +1 -1
- package/dist/reference.d.ts +4 -1
- package/dist/reference.d.ts.map +1 -1
- package/dist/reference.js.map +1 -1
- package/dist/repository.d.ts +1 -0
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +53 -7
- package/dist/repository.js.map +1 -1
- package/dist/runtime.d.ts +0 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +3 -16
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +26 -1
- package/dist/schema.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wake-up-notification.d.ts +8 -0
- package/dist/wake-up-notification.d.ts.map +1 -0
- package/dist/wake-up-notification.js +12 -0
- package/dist/wake-up-notification.js.map +1 -0
- package/dist/worker.js +11 -1
- package/dist/worker.js.map +1 -1
- package/docs/api.md +259 -4
- package/docs/effect-recovery.md +59 -0
- package/docs/parity.md +31 -1
- package/examples/failure-recovery/actor.ts +19 -1
- package/examples/failure-recovery/demo.ts +47 -7
- package/examples/failure-recovery/worker.ts +9 -1
- package/package.json +3 -3
package/docs/api.md
CHANGED
|
@@ -54,8 +54,11 @@ authorization, capability boundaries, and release validation.
|
|
|
54
54
|
- `reference.live`: read-only live signals for an actor, enabled by the
|
|
55
55
|
`solid-objects/signals` entry point documented below.
|
|
56
56
|
- `ActorClass`, `ActorReference`, `ActorMessageSender`, `ActorSnapshot`,
|
|
57
|
-
`ActorOperationNames`, `ActorQueryNames`, `StagedOperations`,
|
|
58
|
-
`ScheduledOperations`: inferred actor-class and
|
|
57
|
+
`ActorOperationNames`, `ActorQueryNames`, `StagedOperations`,
|
|
58
|
+
`ScheduledOperationsFor`, and `ScheduledOperations`: inferred actor-class and
|
|
59
|
+
fluent-dispatch types, plus the legacy dynamic scheduling map.
|
|
60
|
+
- `EffectOptions`: effect arguments and independently checked success/failure
|
|
61
|
+
callback names. Effect names themselves belong to the runtime's global registry.
|
|
59
62
|
- `SnapshotWithIncarnation`: the `{ snapshot, instanceId, revision,
|
|
60
63
|
createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
|
|
61
64
|
- `MessageReference`: immutable durable message identity with `id`,
|
|
@@ -69,6 +72,10 @@ createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
|
|
|
69
72
|
`PayloadBroadcasts`, and `PayloadBroadcastValue` describe actor-declared
|
|
70
73
|
transactional work and typed personalized projections.
|
|
71
74
|
|
|
75
|
+
`EffectFailurePayload<Arguments>`, `EffectSuccessPayload<Arguments, Result>`,
|
|
76
|
+
and `SerializedError` describe effect callback messages. They are also exported
|
|
77
|
+
from the browser-safe `solid-objects/core` entry point.
|
|
78
|
+
|
|
72
79
|
`observables()` returns a flat object. Unwrapped values are invalidation-only:
|
|
73
80
|
their real values participate in change detection, but only their names enter
|
|
74
81
|
the durable envelope. Use an explicit marker when wire behavior matters:
|
|
@@ -126,7 +133,7 @@ operation. If you arm one alarm per queued item, only the last one remains:
|
|
|
126
133
|
// Wrong. Every entry overwrites the previous entry's alarm.
|
|
127
134
|
add({ entry }: { entry: Entry }): void {
|
|
128
135
|
this.entries = [...this.entries, entry]
|
|
129
|
-
this.schedule({ at: new Date(entry.waitUntil) }).deliver
|
|
136
|
+
this.schedule({ at: new Date(entry.waitUntil) }).deliver()
|
|
130
137
|
}
|
|
131
138
|
```
|
|
132
139
|
|
|
@@ -136,7 +143,7 @@ own identifier for the item and names that item's alarm, so each item gets one:
|
|
|
136
143
|
```typescript
|
|
137
144
|
add({ entry }: { entry: Entry }): void {
|
|
138
145
|
this.entries = [...this.entries, entry]
|
|
139
|
-
this.schedule({ at: new Date(entry.waitUntil), key: entry.id }).deliver
|
|
146
|
+
this.schedule({ at: new Date(entry.waitUntil), key: entry.id }).deliver()
|
|
140
147
|
}
|
|
141
148
|
```
|
|
142
149
|
|
|
@@ -155,6 +162,254 @@ row per item. It also cannot strand an entry when the runtime coalesces an
|
|
|
155
162
|
occurrence. Prefer it for a large queue of interchangeable items. Prefer `key`
|
|
156
163
|
when one item needs an alarm that you can move on its own.
|
|
157
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
|
+
|
|
304
|
+
### Typing your onFailure handler
|
|
305
|
+
|
|
306
|
+
An effect callback is an ordinary actor operation. Its payload always includes
|
|
307
|
+
the stable `effectId` and the original serialized `arguments`, including `{}`
|
|
308
|
+
when the effect was emitted without arguments. Use the exported types when a
|
|
309
|
+
watchdog or failure handler needs to correlate work with the current generation:
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
import { Actor, type EffectFailurePayload, type EffectSuccessPayload } from "solid-objects"
|
|
313
|
+
|
|
314
|
+
type RunArguments = { generation: number }
|
|
315
|
+
|
|
316
|
+
class ChatRun extends Actor {
|
|
317
|
+
static override readonly actorType = "ChatRun"
|
|
318
|
+
generation = 0
|
|
319
|
+
status = "idle"
|
|
320
|
+
reply = ""
|
|
321
|
+
|
|
322
|
+
start(): void {
|
|
323
|
+
this.emit("run_model", {
|
|
324
|
+
arguments: { generation: ++this.generation },
|
|
325
|
+
onSuccess: "finishTurn",
|
|
326
|
+
onFailure: "failTurn",
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
failTurn({ arguments: original, error }: EffectFailurePayload<RunArguments>): void {
|
|
331
|
+
if (original.generation !== this.generation) return
|
|
332
|
+
this.status = `${error.name}: ${error.message}`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
finishTurn({
|
|
336
|
+
arguments: original,
|
|
337
|
+
result,
|
|
338
|
+
}: EffectSuccessPayload<RunArguments, { reply: string }>): void {
|
|
339
|
+
if (original.generation !== this.generation) return
|
|
340
|
+
this.status = "finished"
|
|
341
|
+
this.reply = result.reply
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Failure payloads contain `error: SerializedError`, with string `name` and
|
|
347
|
+
`message` fields. They do not include a stack or cause. Success payloads contain
|
|
348
|
+
`result`, which can be any JSON value; an undefined effect return becomes
|
|
349
|
+
`null`. The default argument type is `JsonObject` and the default success result
|
|
350
|
+
type is `JsonValue`. Declare argument shapes with a JSON-compatible type alias.
|
|
351
|
+
|
|
352
|
+
These types describe the SQL and Cloudflare callback envelopes. Error messages
|
|
353
|
+
for non-Error throws retain each backend's existing serialization behavior.
|
|
354
|
+
The generic parameters express your application's contract; they do not add
|
|
355
|
+
runtime validation or infer types from `registerEffect()`. Keep registered
|
|
356
|
+
effect results and the handler's declared argument/result types in agreement.
|
|
357
|
+
|
|
358
|
+
### Typed operation references
|
|
359
|
+
|
|
360
|
+
`schedule` and `transmit` infer this actor's operation names and arguments, including
|
|
361
|
+
inside actor methods and for inherited application operations. The returned
|
|
362
|
+
`ScheduledOperationsFor<ActorType>` values return `void` and preserve required,
|
|
363
|
+
optional, and zero-argument operation signatures. No non-null assertion is needed:
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
class ChatRun extends Actor {
|
|
367
|
+
generation = 0
|
|
368
|
+
status = "idle"
|
|
369
|
+
|
|
370
|
+
start({ generation }: { generation: number }): void {
|
|
371
|
+
this.generation = generation
|
|
372
|
+
this.schedule({ at: new Date(Date.now() + 60_000), key: "watchdog" }).recoverIfStuck({
|
|
373
|
+
generation,
|
|
374
|
+
})
|
|
375
|
+
this.emit("run_model", { arguments: { generation }, onFailure: "failTurn" })
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
recoverIfStuck({ generation }: { generation: number }): void {
|
|
379
|
+
if (generation !== this.generation) return
|
|
380
|
+
this.status = "recovering"
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
failTurn({ error }: { error: { message: string } }): void {
|
|
384
|
+
this.status = error.message
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Misspelled operations/callbacks, state properties, queries, and Actor infrastructure
|
|
390
|
+
are rejected. `emit` checks each callback independently: widening one callback to
|
|
391
|
+
`string` does not disable literal checking of the other. A deliberately widened
|
|
392
|
+
`string` callback retains runtime validation. Object properties can also widen to
|
|
393
|
+
`string`; preserve literals with `as const` or specialize `EffectOptions` to keep
|
|
394
|
+
static checking when options are stored in a variable. Effect and commit-action names remain
|
|
395
|
+
strings because their registries are runtime-wide; inferring registered names needs
|
|
396
|
+
a separate registry typing design.
|
|
397
|
+
|
|
398
|
+
For deliberately dynamic scheduling, retain the exported legacy map explicitly:
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
const dynamicActor: Actor = this
|
|
402
|
+
const operations: ScheduledOperations = dynamicActor.schedule({ at: deadline })
|
|
403
|
+
operations[operationName]!({ generation })
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
This opts out of operation-name and argument inference and retains the existing
|
|
407
|
+
runtime operation checks. Direct calls, queries, and `sendTo` keep their inference.
|
|
408
|
+
Subclasses that override `schedule` or `transmit` with an explicit legacy
|
|
409
|
+
`ScheduledOperations` return annotation must update their override signatures to
|
|
410
|
+
match the generic Actor methods. This is a compile-time compatibility change;
|
|
411
|
+
runtime scheduling and transmission behavior are unchanged.
|
|
412
|
+
|
|
158
413
|
### Runtime managers
|
|
159
414
|
|
|
160
415
|
Every manager below is available as a property on `SolidObjectsRuntime`; the
|
|
@@ -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
|
@@ -29,6 +29,13 @@ such boundary between a gem and its dependents.
|
|
|
29
29
|
|
|
30
30
|
## Status vocabulary
|
|
31
31
|
|
|
32
|
+
Operation-reference typing is runtime-specific: TypeScript infers scheduled and
|
|
33
|
+
transmitted operations from the concrete receiver, and checks literal effect
|
|
34
|
+
callback names. Ruby offers opt-in RBS generation from declared application types
|
|
35
|
+
in [solid-objects-ruby#66](https://github.com/cardmagic/solid-objects-ruby/pull/66).
|
|
36
|
+
Both preserve runtime operation validation and global effect/commit-action names;
|
|
37
|
+
this does not imply automatic TypeScript-style inference in Ruby.
|
|
38
|
+
|
|
32
39
|
- **Native**: the TypeScript runtime provides the capability in a Node-native
|
|
33
40
|
shape.
|
|
34
41
|
- **Partial**: the core exists, but an important Ruby guarantee or operational
|
|
@@ -54,7 +61,7 @@ is needed for the JavaScript stale-result race fix.
|
|
|
54
61
|
| Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. |
|
|
55
62
|
| Backpressure and payload caps | Partial | Serialization enforces a shared maximum JSON nesting depth, raising `InvalidPayload`, and an optional caller-supplied `maxBytes` limit, raising `PayloadTooLarge`; reminder names are bounded to 255 characters. Distributed per-actor rate limits and global admission control do not exist yet, matching the open Ruby roadmap item. |
|
|
56
63
|
| Idle activation cache | Native | Long-running workers retain hydrated actors under renewable fenced leases, restore public state after failed turns, and release on timeout, fairness yield, lease loss, or shutdown. |
|
|
57
|
-
| Transactional effects and outcome operations | Native | At-least-once handlers receive immutable stable effect, attempt, source-message, and actor identity; success and failure operations also receive the originally staged arguments for correlation.
|
|
64
|
+
| Transactional effects and outcome operations | Native | At-least-once handlers receive immutable stable effect, attempt, source-message, and actor identity; success and failure operations also receive the originally staged arguments for correlation. Typed callback envelopes are exported. |
|
|
58
65
|
| Actor-to-actor delivery | Native | `sendTo(reference).operation()` stages delivery in the source actor commit. |
|
|
59
66
|
| One-shot and recurring reminders | Native | Scheduling, replacement events, catch-up policy, stale-claim recovery, pausing, authorized inspection, and idempotent resume are implemented. |
|
|
60
67
|
| Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. |
|
|
@@ -65,6 +72,11 @@ is needed for the JavaScript stale-result race fix.
|
|
|
65
72
|
| Result recovery and sync timeout diagnostics | Native | Status, result, and wait reauthorize the stored operation; terminal failure raises structured `MessageFailed`; whole-call adapter deadlines distinguish enqueue, wait, database, activation, and mailbox blockers. |
|
|
66
73
|
| Result lookup by request ID | Planned | This is also an open Ruby roadmap item and will be implemented in both runtimes when its authorization shape is settled. |
|
|
67
74
|
|
|
75
|
+
Effect callback envelopes are typed with `EffectFailurePayload`,
|
|
76
|
+
`EffectSuccessPayload`, and `SerializedError` in both SQL and Cloudflare.
|
|
77
|
+
Ruby RBS contracts are tracked in cardmagic/solid-objects-ruby#64 and preserve
|
|
78
|
+
Ruby field names; this does not change runtime delivery semantics.
|
|
79
|
+
|
|
68
80
|
## Operations
|
|
69
81
|
|
|
70
82
|
| Capability | Status | TypeScript shape or remaining work |
|
|
@@ -205,3 +217,21 @@ Rails generators, Active Record models/controllers, Turbo rendering, and
|
|
|
205
217
|
Action Cable are not copied into this package. The Rack dashboard is represented
|
|
206
218
|
by the framework-neutral Fetch and Node adapter, renderer callbacks, and the
|
|
207
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
|
-
|
|
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
|
|
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(
|
|
149
|
-
|
|
150
|
-
|
|
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.
|
|
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",
|