Skip to content

DRAFT: support for DO shutdown hooks - #7041

Draft
a-robinson wants to merge 4 commits into
mainfrom
arobinson/do-pre-shutdown
Draft

DRAFT: support for DO shutdown hooks#7041
a-robinson wants to merge 4 commits into
mainfrom
arobinson/do-pre-shutdown

Conversation

@a-robinson

Copy link
Copy Markdown
Member

No description provided.

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).
Comment thread src/workerd/api/actor.h
// 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: comment is a bit overly verbose

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, quick comment on a draft :) Thanks!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But yeah this isn't all cleaned up yet, sorry.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switch to [shutdown] and remove this reserved name logic/tests?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And then the compatibility date can be ripped out too.

Comment thread src/workerd/api/global-scope.h Outdated
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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a reason for "platform" / "system" shutdown reasons that are out of the user's control.

Comment thread src/workerd/io/worker.c++
incomingRequest->delivered();

api::PreShutdownOutcome outcome;
try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Use KJ_TRY/KJ_CATCH

Comment thread src/workerd/io/worker.c++
// 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&&) {});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@a-robinson
a-robinson force-pushed the arobinson/do-pre-shutdown branch from 466e0b0 to 8219cf3 Compare August 18, 2026 03:52
Comment thread src/workerd/io/worker.h

virtual kj::Own<Loopback> addRef() = 0;

// Runs `actor`'s preShutdown() lifecycle hook by supplying the embedder's request

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More verbose comments - I need to go through and trim a bunch down.

Comment thread src/workerd/io/observer.h
class TimerChannel;

namespace api {
enum class PreShutdownOutcome : uint8_t;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably just be using the normal EventOutcome enum.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants