Skip to content

(MOT-4214) feat(queue): per-binding queue subscriptions with metadata delivery - #681

Merged
andersonleal merged 3 commits into
mainfrom
feat/queue-adapter-owned-subscriptions
Aug 3, 2026
Merged

(MOT-4214) feat(queue): per-binding queue subscriptions with metadata delivery#681
andersonleal merged 3 commits into
mainfrom
feat/queue-adapter-owned-subscriptions

Conversation

@andersonleal

@andersonleal andersonleal commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

What

Each durable:subscriber registration now gets its own adapter subscription, and every delivery carries the trigger's stored metadata via the new Invoker::call_delivery. For harness-managed bindings that metadata is the __binding pointer the delivery hop resolves — without it every binding delivery is an unresolvable fire (discovery run 4 lost all 18 messages to exactly that). Two bindings on the same function each get their own queue, their own copy, and their own metadata.

Durable identity

Subscription identity is derived at the trigger layer (RegisteredSubscriber::subscription_key):

  • metadata.__binding when present (harness bindings) — stable across restarts by definition.
  • the target function_id otherwise (plain SDK subscribers) — the pre-existing queue naming, so live deployments keep their queue names and backlogs across the upgrade.

SDK trigger ids are fresh UUIDs per register_trigger call and never key durable state. Duplicate registrations sharing a key share one live subscription (the adapter unsubscribes only when the last one goes), so leftover re-registrations don't multiply deliveries.

Lifecycle semantics

  • unsubscribe (process keeps running): cancel the consumer, drain the in-flight delivery to completion so it acks/nacks, and keep the queue/DLQ/store state — that backlog is exactly what a same-id resubscribe reattaches to. Unsubscribe fires on every routine subscriber disconnect, so it must never destroy data (store.purge is removed along with its only caller).
  • shutdown (process exit): cancel and abort. In-flight jobs are owned by store inflight-recovery / broker redelivery (at-least-once); draining could hang exit behind one blocked invocation.

Review hardening

A /code-review max pass over the branch produced 15 verified findings; all are fixed here. Highlights beyond the identity/lifecycle work above:

  • rabbitmq/redis subscribe hold the write lock from duplicate-check through insert, closing a TOCTOU that leaked an unstoppable duplicate consumer; with queue deletion gone, the unsubscribe/resubscribe race that deleted a freshly re-created queue is dissolved.
  • builtin enqueue snapshots the routing set before store I/O instead of holding the adapter-wide mutex across blocking FileStore writes; the race test asserts observable behavior instead of lock internals.
  • rabbitmq worker drains its JoinSet instead of abort_all; FIFO deliveries borrow instead of deep-cloning metadata per message.
  • IiiInvoker::call delegates to call_delivery; redis unsubscribe_locked and its HashMap-only tests are inlined/removed; the priority e2e predeclares the subscriber-id topology subscribe actually consumes.

Compat

RabbitMQ topics consumed by harness bindings leave the old shared iii.{topic}.{function_id}.queue/.dlq bound to the fanout exchange with no consumer after upgrade — delete those queues manually or they keep accumulating fanout copies. Their backlog is messages the old code was already dropping as unresolvable. Plain-SDK subscriber queue names are unchanged, so their backlogs carry over untouched.

Verification

  • cargo test --all-features: 130 unit + durability/redis-shape tests pass. Docker-backed e2e skipped locally (no docker socket access); missing_dlq_inspection_does_not_close_consumer_channel hard-requires docker and fails on any docker-less machine — pre-existing behavior, gate untouched.
  • cargo clippy --all-targets --all-features -- -D warnings: clean.
  • cargo fmt --check: clean.

Summary by CodeRabbit

  • New Features

    • Added optional metadata to subscriptions and made it available to message handlers.
    • Multiple subscriptions can now share a topic and function while retaining separate metadata.
    • Improved subscription-specific queue and dead-letter handling across supported queue backends.
  • Bug Fixes

    • Improved unsubscribe and shutdown behavior, including orderly cancellation and message redelivery.
    • Prevented duplicate subscriptions and preserved queued messages during subscription changes.
  • Tests

    • Expanded coverage for metadata delivery, fan-out, durability, cancellation, and subscription lifecycle behavior.

Ticket

Queue-worker slice of MOT-4214 (trigger-owning workers dropping the fire-time metadata sidecar).

Each durable:subscriber registration now gets its own adapter
subscription keyed by a stable durable identity, and every delivery
carries the trigger's stored metadata (the harness __binding pointer)
via the new Invoker::call_delivery — without it every harness-binding
delivery is an unresolvable fire (discovery run 4 lost all 18 messages).

Subscription identity is derived at the trigger layer:
metadata.__binding when present (harness bindings), else the target
function id (plain SDK subscribers — the pre-existing queue naming, so
live deployments keep their backlogs across the upgrade). SDK trigger
ids are fresh UUIDs per registration and never key durable state;
duplicate same-key registrations share one live subscription.

Hardening from the workflow code review (15 findings, all addressed):
- unsubscribe is non-destructive on every adapter: cancel the consumer,
  drain the in-flight delivery to completion, keep the queue/DLQ/store
  state a same-id resubscribe reattaches to. It fires on every routine
  subscriber disconnect, so deleting broker queues or purging the store
  there wiped exactly the data the trigger exists to keep (store.purge
  removed again with its only caller).
- shutdown stays abort-based (process exit): in-flight jobs are owned by
  store inflight-recovery / broker redelivery; draining could hang exit
  behind one blocked invocation.
- builtin enqueue snapshots the routing set before store I/O instead of
  holding the adapter-wide mutex across FileStore writes; the race test
  asserts observable behavior instead of lock internals.
- rabbitmq/redis subscribe hold the write lock from duplicate-check
  through insert, closing a TOCTOU that leaked an unstoppable duplicate
  consumer; with queue deletion gone, the unsubscribe/resubscribe race
  that killed a freshly re-created queue is dissolved.
- rabbitmq worker drains its JoinSet instead of abort_all; FIFO
  deliveries borrow instead of deep-cloning metadata per message;
  Worker::run carries the repo-conventional too_many_arguments allow.
- IiiInvoker::call delegates to call_delivery; redis unsubscribe_locked
  and its HashMap-only tests are inlined/removed; the priority e2e
  predeclares the subscriber-id topology subscribe actually consumes.

Compat: RabbitMQ topics consumed by harness bindings leave the old
shared iii.{topic}.{function_id}.queue/.dlq bound to the fanout
exchange with no consumer after upgrade (bindings now use per-binding
queues); delete those manually. Their backlog is messages the old code
was already dropping as unresolvable. Plain-SDK subscriber queue names
are unchanged.
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview Aug 3, 2026 3:06pm
workers-tech-spec Ready Ready Preview Aug 3, 2026 3:06pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The queue subscription API now accepts optional metadata and stable subscription identifiers. Builtin, RabbitMQ, and Redis adapters use per-subscription routing, metadata-aware delivery, duplicate protection, and updated cancellation or unsubscribe behavior. Tests cover fan-out, durability, metadata propagation, and lifecycle handling.

Changes

Subscription contracts and trigger wiring

Layer / File(s) Summary
Subscription contract and trigger lifecycle
queue/src/adapter.rs, queue/src/trigger.rs, queue/src/configuration.rs, queue/src/functions.rs, queue/src/runtime.rs, queue/src/adapters/memory.rs
QueueAdapter::subscribe now accepts metadata. Trigger registrations store metadata, derive stable subscription keys, deduplicate shared subscriptions, and remove adapter subscriptions after the final registration.
Builtin routing and poller lifecycle
queue/src/adapters/builtin.rs, queue/tests/e2e_durability.rs
Builtin routing uses subscription IDs and per-subscription queues. Pollers support cancellation and task draining. Unsubscribe preserves queued and DLQ state, while shutdown handles active tasks according to the adapter lifecycle.
RabbitMQ topology and worker delivery
queue/src/adapters/rabbitmq/*, queue/tests/e2e_rabbitmq.rs
RabbitMQ uses tuple-keyed subscriptions, subscription-specific queues and DLQs, metadata-aware workers, and separate unsubscribe and shutdown handling.
Redis subscription fan-out
queue/src/adapters/redis.rs, queue/tests/e2e_redis.rs
Redis supports multiple subscription IDs per topic, forwards metadata through delivery, prevents duplicate keys, and reports subscriber counts. Tests verify metadata-preserving fan-out.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TriggerManager
  participant QueueAdapter
  participant Worker
  participant Invoker
  TriggerManager->>QueueAdapter: subscribe with stable key and metadata
  QueueAdapter->>Worker: start subscription worker
  Worker->>Invoker: call_delivery with payload and metadata
Loading

Possibly related PRs

Suggested labels: no-ticket

Suggested reviewers: guibeira, ytallo

Poem

A rabbit carries metadata through the queue,
Stable keys guide each message true.
Pollers pause and tasks unwind,
Backlogs wait in storage kind.
Fan-out hops to every door—
Then sleeps beneath the moon once more.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: per-binding queue subscriptions with metadata delivery.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/queue-adapter-owned-subscriptions

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.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 52 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (2)
queue/src/adapters/rabbitmq/adapter.rs (1)

486-526: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

unsubscribe can block forever on a stuck invocation.

sub_info.task_handle.await waits for the worker to finish its drain loop. The worker drains all in-flight process_delivery tasks with no time bound. A single handler that never returns therefore blocks unsubscribe, and unsubscribe runs on the trigger registration path (queue/src/trigger.rs line 492). The caller has no way to recover.

Bound the drain with a timeout and abort on expiry.

🛡️ Proposed fix
-            let _ = sub_info.task_handle.await;
+            // Bound the drain: a handler that never returns must not block
+            // the control plane forever.
+            let drain = tokio::time::timeout(
+                std::time::Duration::from_secs(30),
+                sub_info.task_handle,
+            );
+            if let Err(_elapsed) = drain.await {
+                tracing::warn!(
+                    topic = %topic,
+                    id = %id,
+                    "Timed out draining in-flight deliveries; unacked messages will be redelivered"
+                );
+            }

Note: tokio::time::timeout consumes the JoinHandle, so the task is detached rather than aborted on expiry. If you want the task stopped, keep a clone of the AbortHandle before awaiting.

🤖 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 `@queue/src/adapters/rabbitmq/adapter.rs` around lines 486 - 526, Update
unsubscribe around sub_info.task_handle.await to bound worker shutdown with a
timeout, preserving the existing wait when it completes normally. Capture the
task’s AbortHandle before awaiting, abort the worker if the timeout expires, and
log the timeout or cancellation outcome so unsubscribe always returns even when
process_delivery remains stuck.
queue/src/adapters/builtin.rs (1)

396-423: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

dlq_peek pagination over multiple subscription queues is not stable.

subscription_ids is a HashSet, so the concatenation order of per-queue results varies between calls. With more than one subscription on the topic, two successive dlq_peek(topic, offset, limit) calls with the same arguments can return different rows, and offset skips into an arbitrary concatenation rather than a globally ordered list. A UI that pages through the DLQ can then show duplicates and miss messages. Sorting the collected jobs by a stable field (for example enqueued_at_ms then id) before skip/take makes the page deterministic.

🔧 Proposed stabilization
-        Ok(jobs
+        let mut jobs = jobs;
+        jobs.sort_by(|left, right| {
+            left.enqueued_at_ms
+                .cmp(&right.enqueued_at_ms)
+                .then_with(|| left.id.cmp(&right.id))
+        });
+        Ok(jobs
             .into_iter()
             .skip(offset as usize)
🤖 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 `@queue/src/adapters/builtin.rs` around lines 396 - 423, Update dlq_peek to
sort the combined jobs from multiple subscription queues by stable fields, such
as enqueued_at_ms followed by id, before applying skip(offset) and take(limit).
Preserve the existing single-queue retrieval and pagination behavior while
ensuring HashSet iteration order cannot affect page contents.
🧹 Nitpick comments (5)
queue/tests/e2e_rabbitmq.rs (1)

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

Every RabbitMQ subscribe call passes metadata: None.

The new metadata argument is the main behavior added in this layer, but no RabbitMQ end-to-end test exercises it. The Redis and builtin suites carry the metadata assertions. Add one case here that subscribes with Some(json!({"__binding": "..."})) and asserts the handler receives it through Invoker::call_delivery. This also protects the metadata.as_ref() borrow path in Worker::run for both QueueMode::Fifo and QueueMode::Standard.

Also applies to: 416-416, 493-493

🤖 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 `@queue/tests/e2e_rabbitmq.rs` at line 171, Add RabbitMQ end-to-end coverage
for subscription metadata at the existing subscribe call sites and corresponding
handler assertions, using Some(json!({"__binding": "..."})) and verifying
Invoker::call_delivery receives it. Cover both QueueMode::Fifo and
QueueMode::Standard paths while preserving the existing no-metadata cases.
queue/src/adapters/rabbitmq/adapter.rs (1)

387-421: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The write lock now covers two broker round trips.

subs is held across setup_topic and setup_subscriber_queue. Both calls perform AMQP I/O. If the broker stalls, every other subscription operation stalls with it: unsubscribe, list_topics, and shutdown all need the same RwLock. Duplicate protection only needs the key reserved, not the topology work.

Consider inserting a placeholder or a per-key "in progress" marker, releasing the lock for the topology calls, and re-acquiring it for the final insert.

🤖 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 `@queue/src/adapters/rabbitmq/adapter.rs` around lines 387 - 421, Adjust the
subscription flow around the subscriptions write lock so it does not remain held
during setup_topic and setup_subscriber_queue AMQP calls. Reserve the (topic,
id) key with an in-progress marker while holding the lock, release it for
topology setup, then re-acquire the lock to replace the marker with the
completed subscription or remove it on failure, preserving duplicate-subscribe
protection and cleanup.
queue/src/adapter.rs (1)

198-208: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the new metadata parameter.

The doc comment describes id and function_id only. metadata now carries subscription identity information that adapters must forward to each delivery (see queue/src/trigger.rs RegisteredSubscriber::metadata). Add one line so implementors know the contract is "deliver verbatim with every message".

📝 Proposed doc addition
     /// Register a subscriber (`id`) on `topic` that invokes `function_id`
     /// for each delivered message.
+    /// `metadata` is the subscription's stored trigger metadata; adapters
+    /// must deliver it verbatim with every message via
+    /// [`crate::trigger::Invoker::call_delivery`].
     async fn subscribe(
🤖 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 `@queue/src/adapter.rs` around lines 198 - 208, Update the `subscribe`
documentation to describe the `metadata` parameter, stating that it contains
subscription identity information and must be forwarded verbatim with every
delivered message, consistent with `RegisteredSubscriber::metadata`.
queue/src/trigger.rs (1)

323-341: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Duplicate-key resubscription picks an arbitrary registration's config.

registrations() collects HashMap values, so iteration order is unspecified. When two registrations share (queue, subscription_key) with different queue_config values, the surviving config after a hot swap can differ from the one chosen at register_subscriber time ("first registration's config wins", documented on lines 260-262). To make the choice stable, sort the snapshot by trigger_id before deduplicating.

♻️ Proposed stabilization
     pub async fn resubscribe_all(&self) {
         let mut seen = HashSet::new();
-        for registration in self.registrations().await {
+        let mut registrations = self.registrations().await;
+        registrations.sort_by(|left, right| left.trigger_id.cmp(&right.trigger_id));
+        for registration in registrations {
🤖 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 `@queue/src/trigger.rs` around lines 323 - 341, Update resubscribe_all to sort
the registrations snapshot by trigger_id before deduplicating by queue and
subscription key, ensuring the same first-registration config chosen during
register_subscriber is reused. Preserve the existing deduplication and
subscription behavior after ordering the snapshot.
queue/src/adapters/builtin.rs (1)

326-339: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the "resolve topic to subscription queues" pattern.

The same snapshot-then-aggregate block is repeated in redrive_dlq (326-339), redrive_dlq_message (341-359), discard_dlq_message (361-379), dlq_count (381-394), dlq_peek (396-423), and topic_stats (464-481). Each copy re-implements the lock snapshot, the Some(ids) if !ids.is_empty() guard, and the bare-topic fallback. A single helper keeps the fallback rule in one place, so a future change cannot leave one operation behind.

♻️ Proposed helper
impl BuiltinAdapter {
    /// The internal queue names a bare topic resolves to, or `None` when the
    /// topic has no subscribers and operations target the bare topic name.
    async fn subscription_queue_names(&self, topic: &str) -> Option<Vec<String>> {
        let ids = self.topic_subscriptions.lock().await.get(topic).cloned()?;
        if ids.is_empty() {
            return None;
        }
        Some(
            ids.iter()
                .map(|id| internal_queue_name(topic, id))
                .collect(),
        )
    }
}
     async fn redrive_dlq(&self, topic: &str) -> anyhow::Result<u64> {
-        let subscription_ids = self.topic_subscriptions.lock().await.get(topic).cloned();
-        match subscription_ids {
-            Some(subscription_ids) if !subscription_ids.is_empty() => {
-                let mut total = 0u64;
-                for subscription_id in &subscription_ids {
-                    let queue_name = internal_queue_name(topic, subscription_id);
-                    total += self.store.redrive_dlq(&queue_name).await;
-                }
-                Ok(total)
-            }
-            _ => Ok(self.store.redrive_dlq(topic).await),
-        }
+        let Some(queue_names) = self.subscription_queue_names(topic).await else {
+            return Ok(self.store.redrive_dlq(topic).await);
+        };
+        let mut total = 0u64;
+        for queue_name in &queue_names {
+            total += self.store.redrive_dlq(queue_name).await;
+        }
+        Ok(total)
     }
🤖 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 `@queue/src/adapters/builtin.rs` around lines 326 - 339, Extract the repeated
topic-to-subscription resolution into a BuiltinAdapter helper such as
subscription_queue_names, returning internal queue names for non-empty
subscriber lists and None for missing or empty lists. Update redrive_dlq,
redrive_dlq_message, discard_dlq_message, dlq_count, dlq_peek, and topic_stats
to use this helper while preserving their bare-topic fallback behavior.
🤖 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.

Inline comments:
In `@queue/src/adapters/rabbitmq/worker.rs`:
- Around line 226-233: Update the unexpected termination path in the worker run
flow so a consumer stream ending without explicit cancellation notifies the
RabbitMQ adapter to remove the matching (topic, subscription_id) record from
subscriptions, allowing a later subscribe to recreate the consumer. Preserve the
existing cancellation behavior and warning log, and reuse the adapter’s existing
unsubscribe or cleanup mechanism rather than only logging the termination.

In `@queue/src/trigger.rs`:
- Around line 389-403: Update unregister so the registrations mutex guard is
released before awaiting adapter.unsubscribe: remove the registration and
compute still_shared while holding the lock, then drop the guard and perform the
unsubscribe only when needed. Apply the same release-before-await pattern to
register_subscriber’s adapter.subscribe path if practical, preserving existing
registration and subscription behavior.

In `@queue/tests/e2e_rabbitmq.rs`:
- Line 64: Update the article in the doc comment line that begins with `///
real` to use the correct grammatical article before the word "idempotent".
Change "a idempotent" to "an idempotent" since idempotent begins with a vowel
sound.

In `@queue/tests/e2e_redis.rs`:
- Around line 177-189: In the delivery assertions after wait_for_fires, add an
exact count assertion that deliveries.len() equals 2 before the existing all and
any content checks, preserving the current payload and metadata validations.

---

Outside diff comments:
In `@queue/src/adapters/builtin.rs`:
- Around line 396-423: Update dlq_peek to sort the combined jobs from multiple
subscription queues by stable fields, such as enqueued_at_ms followed by id,
before applying skip(offset) and take(limit). Preserve the existing single-queue
retrieval and pagination behavior while ensuring HashSet iteration order cannot
affect page contents.

In `@queue/src/adapters/rabbitmq/adapter.rs`:
- Around line 486-526: Update unsubscribe around sub_info.task_handle.await to
bound worker shutdown with a timeout, preserving the existing wait when it
completes normally. Capture the task’s AbortHandle before awaiting, abort the
worker if the timeout expires, and log the timeout or cancellation outcome so
unsubscribe always returns even when process_delivery remains stuck.

---

Nitpick comments:
In `@queue/src/adapter.rs`:
- Around line 198-208: Update the `subscribe` documentation to describe the
`metadata` parameter, stating that it contains subscription identity information
and must be forwarded verbatim with every delivered message, consistent with
`RegisteredSubscriber::metadata`.

In `@queue/src/adapters/builtin.rs`:
- Around line 326-339: Extract the repeated topic-to-subscription resolution
into a BuiltinAdapter helper such as subscription_queue_names, returning
internal queue names for non-empty subscriber lists and None for missing or
empty lists. Update redrive_dlq, redrive_dlq_message, discard_dlq_message,
dlq_count, dlq_peek, and topic_stats to use this helper while preserving their
bare-topic fallback behavior.

In `@queue/src/adapters/rabbitmq/adapter.rs`:
- Around line 387-421: Adjust the subscription flow around the subscriptions
write lock so it does not remain held during setup_topic and
setup_subscriber_queue AMQP calls. Reserve the (topic, id) key with an
in-progress marker while holding the lock, release it for topology setup, then
re-acquire the lock to replace the marker with the completed subscription or
remove it on failure, preserving duplicate-subscribe protection and cleanup.

In `@queue/src/trigger.rs`:
- Around line 323-341: Update resubscribe_all to sort the registrations snapshot
by trigger_id before deduplicating by queue and subscription key, ensuring the
same first-registration config chosen during register_subscriber is reused.
Preserve the existing deduplication and subscription behavior after ordering the
snapshot.

In `@queue/tests/e2e_rabbitmq.rs`:
- Line 171: Add RabbitMQ end-to-end coverage for subscription metadata at the
existing subscribe call sites and corresponding handler assertions, using
Some(json!({"__binding": "..."})) and verifying Invoker::call_delivery receives
it. Cover both QueueMode::Fifo and QueueMode::Standard paths while preserving
the existing no-metadata cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 37118b0f-d244-4a91-b41e-b48b2b09f4fa

📥 Commits

Reviewing files that changed from the base of the PR and between 5eaa26f and beee749.

📒 Files selected for processing (17)
  • queue/src/adapter.rs
  • queue/src/adapters/builtin.rs
  • queue/src/adapters/memory.rs
  • queue/src/adapters/rabbitmq/adapter.rs
  • queue/src/adapters/rabbitmq/naming.rs
  • queue/src/adapters/rabbitmq/publisher.rs
  • queue/src/adapters/rabbitmq/retry.rs
  • queue/src/adapters/rabbitmq/topology.rs
  • queue/src/adapters/rabbitmq/worker.rs
  • queue/src/adapters/redis.rs
  • queue/src/configuration.rs
  • queue/src/functions.rs
  • queue/src/runtime.rs
  • queue/src/trigger.rs
  • queue/tests/e2e_durability.rs
  • queue/tests/e2e_rabbitmq.rs
  • queue/tests/e2e_redis.rs

Comment thread queue/src/adapters/rabbitmq/worker.rs
Comment thread queue/src/trigger.rs
Comment thread queue/tests/e2e_rabbitmq.rs Outdated
Comment thread queue/tests/e2e_redis.rs
Close the two coverage gaps worth closing from the review pass:

- rabbitmq e2e (docker-only, no engine): unsubscribe detaches the
  consumer, publishes while detached buffer on the still-bound durable
  queue, and a same-id resubscribe drains them — the broker-side half of
  the non-destructive-unsubscribe contract, previously untested anywhere.
- trigger unit test: resubscribe_all collapses registrations sharing a
  subscription key to one subscribe against the swapped-in adapter.

The remaining untested fixes are the subscribe TOCTOU lock scopes —
inherently racy to pin, left structural.
Review follow-up (MOT-4214). CodeRabbit flagged that unregister holds
the registrations lock across adapter.unsubscribe, which awaited the
consumer drain — up to one whole invocation — stalling every trigger
register/unregister behind one slow job. Releasing the lock (the
suggested fix) would trade that for a race: a same-key register can run
between the handler-lock release and the adapter's map removal, no-op
on the adapter's duplicate check, and end up registered with no live
consumer. Fixed at the root instead: builtin and rabbitmq unsubscribe
now DETACH their worker task (drop the JoinHandle) rather than await
it — the in-flight delivery still completes and acks, unsubscribe
becomes a broker round-trip, and the handler lock stays held so the
no-race property is preserved. The drain unit test now pins the detach
contract (prompt return; delivery still completes and acks).

Also from review: exact delivery-count assert in the redis fan-out e2e
(a duplicate subscription now fails it) and a doc-comment typo.

The consumer-stream-end-leaves-dead-record observation is pre-existing
(same shape before this branch) and tracked as GH-22 (channel-level
error recovery for the shared Arc<Channel>).
@andersonleal andersonleal changed the title feat(queue): per-binding queue subscriptions with metadata delivery (MOT-4214) feat(queue): per-binding queue subscriptions with metadata delivery Aug 3, 2026
@andersonleal
andersonleal merged commit 28fab49 into main Aug 3, 2026
27 of 29 checks passed
@andersonleal
andersonleal deleted the feat/queue-adapter-owned-subscriptions branch August 3, 2026 19:02
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.

1 participant