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.
- package/CHANGELOG.md +13 -0
- package/dist/actor.d.ts +16 -3
- package/dist/actor.d.ts.map +1 -1
- package/dist/actor.js +31 -1
- package/dist/actor.js.map +1 -1
- package/dist/cloudflare/engine.d.ts.map +1 -1
- package/dist/cloudflare/engine.js +7 -1
- 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 +2 -1
- 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/repository.d.ts +1 -0
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +45 -5
- 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 +3 -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 +139 -0
- package/docs/effect-recovery.md +59 -0
- package/docs/parity.md +18 -0
- 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
|
@@ -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
|
-
|
|
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",
|