@@ -162,6 +162,145 @@ row per item. It also cannot strand an entry when the runtime coalesces an
162162occurrence. Prefer it for a large queue of interchangeable items. Prefer ` key `
163163when 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
167306An effect callback is an ordinary actor operation. Its payload always includes
0 commit comments