Skip to content

Add durable execution-queue overflow (opt-in) - #335

Draft
harry-anderson wants to merge 4 commits into
mainfrom
durable-queue-overflow
Draft

Add durable execution-queue overflow (opt-in)#335
harry-anderson wants to merge 4 commits into
mainfrom
durable-queue-overflow

Conversation

@harry-anderson

Copy link
Copy Markdown
Collaborator

Summary

Persist execution-queue messages that would otherwise be dropped, so they survive a queue-full burst or an ungraceful shutdown and are replayed on a later boot instead of being lost.

The feature is gated behind an optional [executor.queue_overflow] config block. When absent (the default) behaviour is unchanged. It requires a persistent storage backend and fails fast at startup otherwise.

What it does

  • Spill-on-full: the Queue Full! log dropped! path now hands the message to a bounded async channel; a dedicated task performs the storage write off the webhook thread, so slow storage cannot block ingress. GET-mode messages (which carry a response channel and cannot be replayed) are excluded.
  • Same-role reload: webhook-role instances claim and reinject persisted messages in batches, backing off when the queue is full. Claiming is atomic (delete-returns-value), so multiple replicas sharing a backend cannot double-inject.
  • Timeboxed spill-on-shutdown: the executor-drain join is bounded; if it does not finish (e.g. a wedged executor), still-queued messages are force-persisted before exit.

Messages are gzip-compressed and keyed by creation time for chronological replay, with a per-item size guard, an approximate persisted-count cap, and a max-age reaper.

Config

[executor.queue_overflow]
max_persisted = 100000       # cap on persisted messages (default)
max_message_age_secs = 86400 # reaper age (default, 24h)
reload_batch_size = 256      # messages reinjected per poll (default)

Notes / limits

  • Cross-instance recovery only applies with a shared backend (e.g. DynamoDB); with a node-local backend each instance sees only its own spilled messages. Documented on the config field.
  • Residual gap: a message already mid-execution on a wedged worker (not in the channel) at a hard kill is still unrecoverable, inherent to at-least-once without write-ahead-on-enqueue.
  • At-least-once replay means rules should tolerate occasional reprocessing.

Test plan

  • cargo check / cargo clippy clean (no new warnings)
  • Unit tests for key ordering, compression roundtrip, persist/reload roundtrip, GET-mode exclusion, cap enforcement/bypass
  • Exercise end-to-end against a real persistent backend under a full-queue burst
  • Verify shutdown spill + boot reload with a deliberately wedged executor

Persist execution-queue messages that would otherwise be dropped, so they
survive a queue-full burst or an ungraceful shutdown and are replayed on a
later boot instead of being lost.

Gated behind an optional [executor.queue_overflow] config block; absent means
the feature is off and behaviour is unchanged. Requires a persistent storage
backend (hard error at startup otherwise).

- Spill-on-full: the "Queue Full! log dropped!" path hands the message to a
  bounded async channel; a dedicated task does the storage write off the
  webhook thread so slow storage cannot block ingress. GET-mode messages are
  excluded since they cannot be replayed.
- Same-role reload: webhook-role pods claim and reinject batches, backing off
  when the queue is full. Claiming is atomic (delete-returns-value) so multiple
  replicas are safe against a shared backend.
- Timeboxed spill-on-shutdown: the executor-drain join is bounded; on timeout
  (the wedged-executor case) still-queued messages are force-persisted.

Messages are gzip-compressed and keyed by creation time for chronological
replay, with a per-item size guard, an approximate persisted-count cap, and a
max-age reaper. Cross-pod recovery only applies with a shared backend such as
DynamoDB (documented on the config field).
@obelisk
obelisk requested review from Copilot and michelemin and removed request for michelemin July 2, 2026 22:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds an opt-in “durable overflow” path for the executor’s bounded execution queue: when the queue is full (or shutdown is forced), eligible messages are persisted to a storage namespace and later replayed on boot by webhook-role instances, reducing loss during bursts or ungraceful termination.

Changes:

  • Adds [executor.queue_overflow] config and startup validation requiring a persistent storage backend.
  • Introduces executor::overflow::OverflowStore for persist/claim/reinject of overflowed messages (gzip-compressed, chronological keys, max-age drop).
  • Implements spill-on-full (bounded async channel to offload storage writes), same-role reload polling, and a timeboxed shutdown drain that persists still-queued messages.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
runtime/plaid/src/storage/mod.rs Exposes Storage::is_persistent() to support startup validation of the feature.
runtime/plaid/src/executor/overflow.rs New durable overflow store implementation (persist + reload/claim logic, compression, tests).
runtime/plaid/src/executor/mod.rs Exports the new overflow module.
runtime/plaid/src/config.rs Adds [executor.queue_overflow] config struct and documentation.
runtime/plaid/src/bin/plaid.rs Wires spill channel, reload task, persistent-backend validation, and shutdown drain behavior into the Plaid binary.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread runtime/plaid/src/executor/overflow.rs Outdated
};
// We removed a row that we had counted; reflect that regardless of what
// happens next (reinjected, reaped, or dropped as corrupt).
self.count.fetch_sub(1, Ordering::Relaxed);
Comment thread runtime/plaid/src/executor/overflow.rs Outdated
Comment on lines +240 to +243
Err(TrySendError::Disconnected(_)) => {
error!("Executor channel disconnected while reloading overflow {key}");
break;
}
Comment thread runtime/plaid/src/bin/plaid.rs Outdated
maybe_msg = rx.recv() => {
match maybe_msg {
// None means all senders dropped; nothing left to persist.
Some(message) => { let _ = store.persist(&message).await; }
Comment thread runtime/plaid/src/bin/plaid.rs Outdated
// executor-drain step at the end of main(), which is the real
// backstop for un-enqueued messages during shutdown.
while let Ok(message) = rx.try_recv() {
let _ = store.persist(&message).await;
Comment thread runtime/plaid/src/bin/plaid.rs Outdated
Comment on lines +831 to +834
let join_handle = tokio::task::spawn_blocking(move || executor_threads.join());
match tokio::time::timeout(EXECUTOR_DRAIN_DEADLINE, join_handle).await {
Ok(_) => info!("Executor threads drained cleanly"),
Err(_) => {
Comment thread runtime/plaid/src/config.rs Outdated
Comment on lines +165 to +166
/// How many overflow messages to claim and reinject per reload poll. Keeps a single
/// poll from loading an unbounded namespace into memory. Defaults to 256.
Redesign claim as ready→inflight lease so a crash mid-reload no longer
drops messages after delete. Add saturating count accounting, age reaper,
base64 wire format (smaller items), forced-spill on shutdown, detached
executor join on drain timeout, and broader unit tests for the failure
modes found in adversarial QA.
Resolve conflicts with 429-on-full and Prometheus metrics:
- Keep durable spill on queue-full; return 429 only if spill also fails
- Export both metrics and overflow executor modules
Address adversarial QA v2 findings for reliability under spikes:

- Honest HTTP admission: 202 when accepted onto spill, 429 under pressure
- Concurrent spill workers (configurable) instead of a single serial writer
- Spill high-watermark + recent-failure backpressure before channel hard-fill
- Bounded list_keys_limited (oldest-first) for reload/reap/reclaim
- Reinject high-watermark to avoid claim→restore thrash on near-full queues
- Age reaper on a slower cadence than reload
- Binary v2 envelope (legacy v1/json still decode); faster gzip
- Prometheus overflow metrics (depth, persist outcomes, reload events)
- Route interval/SQS/GitHub/Okta/websocket through send_or_spill
- Higher default spill capacity (4096) and concurrency (8)
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