Skip to content

fix(iii-queue): add per-queue dispatch_timeout_ms to bound lost dispatches#1929

Open
ytallo wants to merge 2 commits into
mainfrom
bugfix/mot-3786-fn-queue-dispatch-timeout
Open

fix(iii-queue): add per-queue dispatch_timeout_ms to bound lost dispatches#1929
ytallo wants to merge 2 commits into
mainfrom
bugfix/mot-3786-fn-queue-dispatch-timeout

Conversation

@ytallo

@ytallo ytallo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What

Adds an opt-in per-queue dispatch_timeout_ms to queue_configs entries. When set, a fn-queue invocation that has been dispatched but not completed within the deadline is abandoned and nacked back through the normal retry→DLQ path, releasing its concurrency slot. When unset (the default), behaviour is unchanged.

  • New Engine::call_with_timeout(function_id, input, Option<Duration>): bounds the invocation, and on elapse evicts the abandoned invocation from both tracking structures — the engine-global invocations map and the owning worker's in-flight set — then returns a dispatch_timeout error. EngineTrait::call now delegates to it with None (no behaviour change for other callers).
  • The fn-queue consumer (QueueWorker::spawn_consumer) dispatches through it, so a timeout lands in the existing nack→retry→DLQ arm.
  • dispatch_timeout_ms: 0 is rejected at both the JSON schema (range(min = 1)) and the config.yaml seed path (QueueModuleConfig::validate), mirroring the existing concurrency/max_priority guards.
  • Docs (config field, README, SKILL.md) document the new knob and its caveat: the timeout does not cancel work already running on a worker, so a retry can overlap the original attempt — bounded handlers must be idempotent.

Why

Fixes #1927. When the engine dequeues a fn-queue message and the engine→worker dispatch is lost (or the worker never returns a result), the message's ack/nack was gated behind an unbounded await: the fn-queue consumer awaited the invocation with no deadline, and for a worker-routed (deferred) invocation the engine parked on a oneshot that only resolves on a worker reply or a detected disconnect. A half-open connection resolves neither. Since max_retries/backoff_ms/DLQ are nack-driven, a dispatched-but-uncompleted message was never retried — it stayed unacked until RabbitMQ's broker consumer_timeout (~30 min), pinned one of the queue's concurrency slots the whole time, and survived worker restarts because the engine holds the AMQP consumer.

Notes

  • Scope: covers named fn-queues for all adapters (their consumption funnels through the single dispatch site in queue.rs). The topic/subscriber (durable:subscriber) delivery path has the same unbounded-await exposure and is tracked separately (MOT-3801), along with centralizing the deadline in InvocationHandler and active worker-liveness detection.
  • At-least-once semantics unchanged: a timed-out dispatch redelivers, same as a broker-timeout redelivery today — the knob just makes recovery configurable per queue instead of fixed at ~30 min.
  • Also fixes an eager unwrap_or(Uuid::new_v4()) in handle_invocation that would otherwise draw a discarded UUID per invocation now that the id is generated up front.

Test plan

  • cargo test -p iii --lib — 1900 passed (includes 3 new call_with_timeout unit tests: deferred-eviction from the invocations map, worker in-flight set cleanup, None stays unbounded)
  • cargo test -p iii --test queue_integration — 15 passed (new: wedged handler dead-letters via timeout; timeout releases the concurrency slot for a queued healthy message; unset timeout leaves a slow handler unbounded)
  • Config: parse/default/Some(0)-rejection unit tests
  • cargo fmt --check, cargo clippy clean on touched files
  • Manual repro against a real RabbitMQ broker (docker compose) with a wedged worker — verify messages_unacknowledged drains at the configured deadline instead of ~30 min

Summary by CodeRabbit

  • New Features
    • Added optional per-queue dispatch timeouts for function jobs via dispatch_timeout_ms (JSON: dispatchTimeoutMs), applied to dispatched work.
  • Bug Fixes
    • Timed-out dispatches now follow the normal retry → dead-letter path instead of staying in-flight.
    • Improved timeout cleanup so queue capacity/concurrency is freed for subsequent jobs.
    • Long-running handlers remain unaffected when timeouts are unset (bounded only when configured).
  • Tests
    • Added integration coverage for wedged dispatch recovery, retry/dead-letter behavior, capacity freeing, and unset-timeout success.

@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
iii-website Ready Ready Preview, Comment Jul 6, 2026 11:15am
tech-spec Error Error Jul 6, 2026 11:15am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0a2e0c91-0f6e-4cf0-bcc5-c56b978faaa4

📥 Commits

Reviewing files that changed from the base of the PR and between 2536b7f and 74026c7.

📒 Files selected for processing (11)
  • engine/src/engine/mod.rs
  • engine/src/invocation/mod.rs
  • engine/src/worker_connections/mod.rs
  • engine/src/workers/queue/README.md
  • engine/src/workers/queue/adapters/builtin/adapter.rs
  • engine/src/workers/queue/config.rs
  • engine/src/workers/queue/queue.rs
  • engine/src/workers/queue/skills/SKILL.md
  • engine/src/workers/queue/subscriber_config.rs
  • engine/tests/common/queue_helpers.rs
  • engine/tests/queue_integration.rs
✅ Files skipped from review due to trivial changes (2)
  • engine/src/workers/queue/skills/SKILL.md
  • engine/src/workers/queue/README.md
🚧 Files skipped from review as they are similar to previous changes (9)
  • engine/src/invocation/mod.rs
  • engine/src/workers/queue/queue.rs
  • engine/tests/queue_integration.rs
  • engine/src/workers/queue/config.rs
  • engine/src/worker_connections/mod.rs
  • engine/tests/common/queue_helpers.rs
  • engine/src/workers/queue/subscriber_config.rs
  • engine/src/workers/queue/adapters/builtin/adapter.rs
  • engine/src/engine/mod.rs

📝 Walkthrough

Walkthrough

The PR adds timeout-aware engine invocation, threads queue dispatch deadlines from configuration into worker execution, and updates tests and docs to cover timeout expiry, cleanup, and the unset-timeout path.

Changes

Dispatch timeout flow

Layer / File(s) Summary
Timeout-aware engine dispatch
engine/src/engine/mod.rs, engine/src/invocation/mod.rs, engine/src/worker_connections/mod.rs
Adds a timeout-capable engine call path, delegates metadata-based calls to it, lazily creates invocation ids, and removes timed-out invocations from engine and worker tracking.
Queue timeout configuration
engine/src/workers/queue/config.rs, engine/src/workers/queue/README.md, engine/src/workers/queue/skills/SKILL.md, engine/src/workers/queue/subscriber_config.rs
Adds the dispatch_timeout_ms queue setting, subscriber parsing, defaulting, validation, and documentation.
Queue dispatch wiring
engine/src/workers/queue/queue.rs, engine/src/workers/queue/adapters/builtin/adapter.rs
Derives a per-invocation timeout from queue config and passes it into engine dispatch.
Timeout tests and helpers
engine/tests/common/queue_helpers.rs, engine/tests/queue_integration.rs, engine/src/engine/mod.rs, engine/src/workers/queue/config.rs, engine/src/workers/queue/adapters/builtin/adapter.rs, engine/src/workers/queue/subscriber_config.rs
Adds a wedged test function and coverage for timeout eviction, worker cleanup, queue slot release, and the no-timeout path.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • iii-hq/iii#1928: This PR is directly connected through the EngineTrait::call_with_metadata delegation path and invocation metadata handling.

Suggested reviewers: guibeira, kbtale

Poem

🐇 I hop through timers, neat and small,
I nudge the queue so jobs can fall
Back to retry paths, then DLQ light,
While invocation tracks stay tight.
With thumps and hops, the engine sings,
And dispatch timeouts sprout their wings.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding per-queue dispatch timeout support to bound lost dispatches.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/mot-3786-fn-queue-dispatch-timeout

Warning

Tools execution failed with the following error:

Failed to run tools: 13 INTERNAL: Received RST_STREAM with code 2 (Internal server error)


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

guibeira
guibeira previously approved these changes Jul 2, 2026

@guibeira guibeira 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.

LGTM

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
engine/src/workers/queue/queue.rs (1)

835-846: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Block dispatch_timeout_ms on FIFO queues
dispatch_timeout_ms releases the only FIFO permit while the original worker-side invocation can still be running, so a later message in the same message_group_field can start before the first attempt finishes. That breaks the strict per-group ordering guarantee; reject fifo + dispatch_timeout_ms in validation or document the limitation clearly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@engine/src/workers/queue/queue.rs` around lines 835 - 846, The FIFO queue
path in queue::worker::queue::dispatch setup allows dispatch_timeout_ms to be
used together with fifo, which can release the single permit before the original
invocation finishes and violate per-group ordering. Update the validation/config
handling around QueueConfig and the prefetch/dispatch_timeout logic to reject or
disable dispatch_timeout_ms whenever config.r#type is fifo, and make the
constraint explicit near the dispatch_timeout variable setup so the behavior is
enforced consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@engine/src/workers/queue/queue.rs`:
- Around line 835-846: The FIFO queue path in queue::worker::queue::dispatch
setup allows dispatch_timeout_ms to be used together with fifo, which can
release the single permit before the original invocation finishes and violate
per-group ordering. Update the validation/config handling around QueueConfig and
the prefetch/dispatch_timeout logic to reject or disable dispatch_timeout_ms
whenever config.r#type is fifo, and make the constraint explicit near the
dispatch_timeout variable setup so the behavior is enforced consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9775dac0-94b0-42cd-a782-424f3646f1be

📥 Commits

Reviewing files that changed from the base of the PR and between 62ce7b1 and 773d307.

📒 Files selected for processing (9)
  • engine/src/engine/mod.rs
  • engine/src/invocation/mod.rs
  • engine/src/worker_connections/mod.rs
  • engine/src/workers/queue/README.md
  • engine/src/workers/queue/config.rs
  • engine/src/workers/queue/queue.rs
  • engine/src/workers/queue/skills/SKILL.md
  • engine/tests/common/queue_helpers.rs
  • engine/tests/queue_integration.rs
✅ Files skipped from review due to trivial changes (1)
  • engine/src/workers/queue/skills/SKILL.md

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (3)
engine/src/workers/queue/subscriber_config.rs (1)

122-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Silent zero-timeout handling diverges from FunctionQueueConfig's hard validation.

dispatch_timeout() treats dispatch_timeout_ms: Some(0) as invalid by logging a warning and silently returning None (unbounded). By contrast, FunctionQueueConfig validation (engine/src/workers/queue/config.rs) rejects the same value outright with anyhow::bail!. This means a subscriber-config user who sets dispatchTimeoutMs: 0 gets no dispatch timeout at all with only a log line, whereas a fn-queue user gets a hard config-load failure for the identical mistake. This is called out as intentional in the commit summary, but the asymmetry between the two nearly-identical config surfaces could confuse operators debugging why their subscriber queue "isn't timing out."

Consider surfacing this at config-validation time (where the caller has more context to fail loudly) rather than only warning when dispatch_timeout() happens to be invoked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@engine/src/workers/queue/subscriber_config.rs` around lines 122 - 136, The
zero-value handling in dispatch_timeout() is only warning and silently falling
back to unbounded behavior, which is inconsistent with FunctionQueueConfig
validation. Move the invalid dispatch_timeout_ms == Some(0) check into
subscriber config validation for SubscriberConfig/dispatch_timeout_ms so the
config load fails loudly with a clear error, and keep dispatch_timeout() only
responsible for converting already-validated values into Duration.
engine/src/workers/queue/adapters/builtin/adapter.rs (2)

704-926: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a dispatch_timeout_ms coverage case for subscribe() — The current adapter tests only exercise dispatch_timeout_ms: None; add one topic-subscribe case with a wedged handler to assert the timeout is passed through to call_with_timeout and drives nack/DLQ behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@engine/src/workers/queue/adapters/builtin/adapter.rs` around lines 704 - 926,
The subscribe tests in adapter.rs do not cover dispatch_timeout_ms being set, so
add a new subscribe case with a wedged handler and a non-None
dispatch_timeout_ms to verify the timeout is propagated through
FunctionHandler/call_with_timeout and results in the expected nack/DLQ behavior.
Use the existing builtin adapter test setup around make_adapter, subscribe, and
FunctionHandler to locate the change, and assert both the timeout path and the
DLQ side effect.

225-242: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Document dispatchTimeoutMs in subscriber queue_config examples. durable:subscriber already accepts this key, and the SDK builders pass queue_config through as a generic payload, so the gap is in the docs/examples (for example, docs/next/creating-workers/queues.mdx and docs/next/api-reference/sdk-rust.mdx).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@engine/src/workers/queue/adapters/builtin/adapter.rs` around lines 225 - 242,
The subscriber queue_config examples are missing documentation for
dispatchTimeoutMs even though durable:subscriber supports it and subscribe()
already reads it via dispatch_timeout(); update the relevant docs/examples (such
as the queue guide and Rust SDK reference) to show dispatchTimeoutMs in
queue_config payloads, using the existing subscribe/SubscriberQueueConfig
terminology so readers can connect the docs to the runtime behavior.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@engine/src/workers/queue/adapters/builtin/adapter.rs`:
- Around line 704-926: The subscribe tests in adapter.rs do not cover
dispatch_timeout_ms being set, so add a new subscribe case with a wedged handler
and a non-None dispatch_timeout_ms to verify the timeout is propagated through
FunctionHandler/call_with_timeout and results in the expected nack/DLQ behavior.
Use the existing builtin adapter test setup around make_adapter, subscribe, and
FunctionHandler to locate the change, and assert both the timeout path and the
DLQ side effect.
- Around line 225-242: The subscriber queue_config examples are missing
documentation for dispatchTimeoutMs even though durable:subscriber supports it
and subscribe() already reads it via dispatch_timeout(); update the relevant
docs/examples (such as the queue guide and Rust SDK reference) to show
dispatchTimeoutMs in queue_config payloads, using the existing
subscribe/SubscriberQueueConfig terminology so readers can connect the docs to
the runtime behavior.

In `@engine/src/workers/queue/subscriber_config.rs`:
- Around line 122-136: The zero-value handling in dispatch_timeout() is only
warning and silently falling back to unbounded behavior, which is inconsistent
with FunctionQueueConfig validation. Move the invalid dispatch_timeout_ms ==
Some(0) check into subscriber config validation for
SubscriberConfig/dispatch_timeout_ms so the config load fails loudly with a
clear error, and keep dispatch_timeout() only responsible for converting
already-validated values into Duration.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 93be002d-d55a-4ad5-b09a-5014d78c5f3b

📥 Commits

Reviewing files that changed from the base of the PR and between 773d307 and 2536b7f.

📒 Files selected for processing (2)
  • engine/src/workers/queue/adapters/builtin/adapter.rs
  • engine/src/workers/queue/subscriber_config.rs

ytallo added 2 commits July 6, 2026 08:15
…tches

A queued fn-queue invocation dispatched to a worker could stay unacked
until RabbitMQ's consumer_timeout (~30 min) if the dispatch was lost or
the worker never returned a result: the message's ack/nack was bound to
an unbounded await, and the retry->DLQ path is nack-driven, so a
dispatched-but-uncompleted message was never retried and pinned one of
the queue's concurrency slots for the whole window (#1927).

Add an opt-in per-queue dispatch_timeout_ms. The fn-queue consumer now
dispatches via a new Engine::call_with_timeout, which bounds the wait,
evicts the abandoned invocation from both tracking structures (the
engine invocations map and the owning worker's in-flight set), and
returns a dispatch_timeout error that flows through the existing
nack->retry->DLQ path and releases the concurrency slot.

- 0 is rejected at both the JSON schema and config.yaml seed layers
- unset preserves the historical unbounded behaviour
- docs warn the timeout does not cancel worker-side work, so bounded
  handlers must be idempotent

Closes #1927
Rename the subscriber config's visibilityTimeout key (parsed but never
enforced) to dispatchTimeoutMs and thread it into the builtin adapter's
FunctionHandler, which now dispatches via Engine::call_with_timeout so
subscriber deliveries get the same bounded-dispatch semantics as the
fn-queue consumer path.

- 0 is rejected with a warning and treated as unbounded
- unset preserves the historical unbounded behaviour
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.

fn-queue invocation can wedge unacked forever — no per-invocation dispatch timeout to requeue a lost engine→worker dispatch

2 participants