DRAFT: support for DO shutdown hooks - #7041
Conversation
Adds the API surface for the preShutdown() lifecycle hook on
Durable Object classes, gated by the new experimental
durable_object_pre_shutdown compatibility flag. The hook is
invoked on a best-effort basis before planned, storage-healthy
shutdowns -- currently idle eviction of root objects; the reason
list is extensible so further planned reasons (such as code-update
resets, for which "codeUpdated" is already reserved) can be
delivered later without another flag -- giving objects a bounded
window to checkpoint state. Dispatch and trigger machinery land
separately; this change only defines the handler surface:
- ExportedHandler gains an optional preShutdown member which
receives a PreShutdownInfo describing the shutdown reason.
Handlers must tolerate unknown reasons, so the TypeScript type
of info.reason is the open union
'inactive' | 'codeUpdated' | (string & {}), keeping the known
literals autocompletable.
- With the flag enabled, "preShutdown" joins the RPC
reserved-name list (entrypoint-wide, like other reserved
names). The reservation is checked against the target worker's
compatibility flags, since existing classes may already export
an RPC method with this name. Direct (local) invocation by user
code remains allowed.
- validateHandlers() treats preShutdown like alarm (a lifecycle
hook, not an entrypoint handler) when the flag is enabled.
- TypeScript: the DurableObject interface and the stub's RPC
exclusion list include preShutdown only when the flag is
enabled; the class-based DurableObject type in defines/rpc.d.ts
documents the hook unconditionally (matching the tailStream
precedent for experimental handlers).
| // The JSG_TS_OVERRIDE renames this resource type to DurableObjectStub, and makes DurableObject | ||
| // the interface implemented by users' Durable Object classes. `preShutdown` is only reserved | ||
| // (and invoked by the runtime) when the `durable_object_pre_shutdown` compatibility flag is | ||
| // enabled, so it is only excluded from the stub's RPC surface under that flag. |
There was a problem hiding this comment.
Nit: comment is a bit overly verbose
There was a problem hiding this comment.
Wow, quick comment on a draft :) Thanks!
There was a problem hiding this comment.
But yeah this isn't all cleaned up yet, sorry.
There was a problem hiding this comment.
No worries ;-) ... I know it's early. I just happened to see it pop up and was waiting for a compile to finish :-D
| // preShutdown is reserved by the Durable Objects implementation, but only when the target | ||
| // worker has opted in via the compatibility flag, since existing classes may already export | ||
| // an RPC method with this name. | ||
| if (name == "preShutdown" && FeatureFlags::get(js).getDurableObjectPreShutdown()) { |
There was a problem hiding this comment.
Switch to [shutdown] and remove this reserved name logic/tests?
There was a problem hiding this comment.
And then the compatibility date can be ripped out too.
| public: | ||
| // Why the Durable Object is being shut down. This list may grow over time; handlers must | ||
| // tolerate reason strings they don't recognize. | ||
| enum class Reason { |
There was a problem hiding this comment.
Add a reason for "platform" / "system" shutdown reasons that are out of the user's control.
| incomingRequest->delivered(); | ||
|
|
||
| api::PreShutdownOutcome outcome; | ||
| try { |
| // output gate; a broken gate rejects this promise, in which case teardown just proceeds. | ||
| KJ_IF_SOME(persistent, getPersistent()) { | ||
| KJ_IF_SOME(flush, persistent.onNoPendingFlush(SpanParent(nullptr))) { | ||
| co_await flush.catch_([](kj::Exception&&) {}); |
There was a problem hiding this comment.
Nit: Mixing coroutine and then/catch_syntax makes me sad.
Adds the machinery that actually invokes the preShutdown() Durable Object lifecycle hook, and wires it into workerd's graceful eviction paths for local-dev parity: - ServiceWorkerGlobalScope::runPreShutdown(), modeled on runAlarm: awaits the handler racing a wall-clock budget from the new LimitEnforcer::getPreShutdownLimit() (default 10s). Unlike the alarm timeout, hitting the budget does NOT abort the IoContext: the runtime just stops waiting, so writes the handler already issued still flush. The budget is also not preemptive: the timer only fires once the isolate yields, so it bounds handlers that await too long, while CPU-bound handlers are bounded by the embedder's CPU enforcement like any other event. The hook runs with no per-request AsyncLocalStorage context. Exceptions never propagate and teardown keeps its benign classification: user-attributable errors (tunneled or marked EXCEPTION_IS_USER_ERROR -- the handler threw, rejected, or hard-aborted its own actor) are logged to the user's observability (new UncaughtExceptionSource PRE_SHUTDOWN_HANDLER) with outcome THREW, while internal failures that interrupt the handler (e.g. the IoContext aborted by a brokenness path racing the shutdown) are not reported as the handler's exception and yield outcome FAILED. - Worker::Actor::runPreShutdown(): the delivery entry point for shutdown paths. It deliberately avoids Worker::Actor::addRef() (a normal strong reference creates a RequestTracker ActiveRequest whose active() callback would cancel the very shutdown that triggered the hook) and instead runs the hook on a lightweight IncomingRequest built from caller-provided pieces, while pinning the actor with a plain kj::addRef() for the coroutine's duration: a hard abort (ctx.abort(), abortAllDurableObjects(), a brokenness path) can drop the embedder's owning reference mid-suspension, which cuts the hook short (outcome FAILED) but must be memory-safe. After the handler settles (or times out), it awaits onNoPendingFlush() so the handler's storage writes are durable before teardown proceeds; this wait is already bounded by the storage-hang timeout. The IncomingRequest is destroyed synchronously rather than via drain(): for actors drain() only completes on shutdown (which happens after the hook), and a deferred destruction would outlive the actor and leave IoContext::actor dangling. A new IncomingRequest::abandonTasksForActorShutdown() suppresses the missed-drain warning for this teardown-sequence pattern. The synchronous handler-presence checks live in a public hasPreShutdownHandler(); when the actor has no applicable handler (flag off, class never constructed, or no preShutdown method), runPreShutdown() returns kj::none synchronously so shutdown paths don't suspend at all. This matters: even awaiting an immediately-ready promise costs event-loop turns, which measurably widened the window in which a racing request cancels an in-flight eviction (caught by the websocket-hibernation server test). - workerd trigger: ActorContainer::handleShutdown() (inactivity timer) and tryEvict() (test-only eviction) run the hook with reason "inactive" before hibernateWebSockets(), so a final ws.send() works, via a new Server::ActorClass::runPreShutdown() hop that supplies the IoChannelFactory and observer. Only the root actor gets the hook, matching production: facets would otherwise accidentally acquire per-facet ordering and timeout semantics that haven't been designed. If a new request arrives mid-hook on the inactivity path, workerd's existing arrival-cancels-eviction behavior revives the actor (documented divergence from production's committed-shutdown semantics; friendlier for dev). Since the hook adds suspension points to both paths, a teardownInProgress latch serializes graceful teardown attempts per container -- evictAllDurableObjects() reaches the same namespace once per bound channel (env binding plus ctx.exports self-binding) -- with the inactivity path polling the latch rather than returning, since the eviction it defers to can back off and leave an idle actor with no transition to re-arm. Both paths re-check the actor slot after every suspension against a raw pointer captured beforehand (the Maybe's contents are not safe to touch if an abort cleared the slot meanwhile). On the inactivity path, onBrokenTask stays armed until teardown fully succeeds, so a hook that breaks its own actor is handled by monitorOnBroken rather than leaving a broken actor installed with no broken-detection; a pinned container is hollowed rather than erased, so cancellation alone cannot be relied on there. tryEvict(), which active() does not cancel, additionally treats a racing request as reviving the actor (dev semantics) and retries once it is idle again. - PreShutdownReason/PreShutdownOutcome are namespace-scope enums with fixed underlying types so worker.h can forward-declare them. Tests: do-pre-shutdown-test.wd-test covers checkpoint visibility to the successor (fire-and-forget put), awaited async handlers, throwing handlers (writes still drained, eviction proceeds), classes without the method, setAlarm-from-hook resurrection, flag-off non-invocation, a racing request arriving mid-hook, a hook that hard-aborts its own actor (reproduces the use-after-free the pin prevents), and root-only delivery on a facet tree.
Worker::Actor::Loopback gains a runPreShutdown() method that embedders can implement to supply their request infrastructure (IoChannelFactory, RequestObserver, tracer) for delivering the preShutdown() lifecycle hook. Like hibernatable WebSocket events, the hook must be deliverable when no inbound request exists, and the loopback is the object that captures the embedder context that requires. The default implementation returns kj::none; workerd's local server keeps triggering the hook through its own service objects instead.
Adds ActorObserver::preShutdownFinished(), called from Worker::Actor::runPreShutdownImpl() with the outcome of every hook run, including the early-exit case where the IoContext is already gone. Shutdowns that skip the hook synchronously (no applicable handler) are deliberately not reported, so observers only see actors that participate in the hook. The default implementation is a no-op; embedders override it to count outcomes.
466e0b0 to
8219cf3
Compare
|
|
||
| virtual kj::Own<Loopback> addRef() = 0; | ||
|
|
||
| // Runs `actor`'s preShutdown() lifecycle hook by supplying the embedder's request |
There was a problem hiding this comment.
More verbose comments - I need to go through and trim a bunch down.
| class TimerChannel; | ||
|
|
||
| namespace api { | ||
| enum class PreShutdownOutcome : uint8_t; |
There was a problem hiding this comment.
This should probably just be using the normal EventOutcome enum.
No description provided.