Skip to main content

reddb_server/runtime/
impl_queue.rs

1//! Queue DDL and command execution
2
3use std::collections::{HashMap, HashSet};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Arc;
6
7use crate::runtime::audit_log::{AuditAuthSource, AuditEvent, AuditFieldEscaper, Outcome};
8use crate::runtime::impl_core::{
9    clear_current_auth_identity, clear_current_tenant, current_auth_identity,
10    current_connection_id, current_tenant, set_current_auth_principal, set_current_tenant,
11};
12use crate::runtime::queue_telemetry::NackOutcomeLabel;
13use crate::storage::queue::QueueMode;
14use crate::storage::unified::entity::{QueueMessageData, RowData};
15use crate::storage::unified::{Metadata, MetadataValue, UnifiedStore};
16use crate::telemetry::operator_event::OperatorEvent;
17
18use super::*;
19
20use super::primary_queue_store::PrimaryQueueStore;
21use super::queue_lifecycle::{QueueLifecycle, RetirementOutcome};
22use crate::storage::queue::lifecycle::{
23    QueueSide as LcQueueSide, QueueStore as _, QueueStoreError, QueueTxn,
24};
25
26/// Build a [`QueueLifecycle`] backed by a fresh [`PrimaryQueueStore`] for
27/// the given `queue`, plus a `QueueTxn` bound to the live runtime
28/// connection. The store inside the lifecycle and the standalone
29/// [`PrimaryQueueStore`] returned for ack/nack lookups share the same
30/// underlying [`UnifiedStore`] — calls against either are observable on
31/// the other.
32pub(super) fn runtime_lifecycle(
33    runtime: &RedDBRuntime,
34    queue: &str,
35) -> (
36    QueueLifecycle<PrimaryQueueStore>,
37    PrimaryQueueStore,
38    QueueTxn,
39) {
40    let primary_for_lookup = PrimaryQueueStore::new(runtime.clone());
41    let primary_for_lifecycle = PrimaryQueueStore::new(runtime.clone());
42    let txn = primary_for_lifecycle.new_txn();
43    let cfg = primary_for_lifecycle.lifecycle_config(queue);
44    (
45        QueueLifecycle::new(primary_for_lifecycle, cfg)
46            .with_telemetry(Arc::clone(&runtime.inner.queue_telemetry)),
47        primary_for_lookup,
48        txn,
49    )
50}
51
52/// Slice C of PRD #718 — error surfaced to callers when the wait
53/// registry is cancelled (server shutdown) while a `QUEUE READ … WAIT`
54/// is parked. Kept as a plain `RedDBError::Query` so transports
55/// inherit the message unchanged — there is no separate `Cancelled`
56/// variant on the public error today.
57pub(crate) const QUEUE_READ_WAIT_CANCELLED: &str =
58    "QUEUE READ WAIT cancelled — server shutting down";
59
60/// Slice B of PRD #718 — `red.config` key naming the maximum WAIT
61/// budget the runtime will honour. Values above the cap are rejected
62/// before any waiter is registered.
63pub(crate) const QUEUE_MAX_WAIT_MS_CONFIG_KEY: &str = "red.config.queue.max_wait_ms";
64
65/// Default cap when the operator has not set
66/// [`QUEUE_MAX_WAIT_MS_CONFIG_KEY`] — 60 seconds, in milliseconds.
67pub(crate) const QUEUE_MAX_WAIT_MS_DEFAULT: u64 = 60_000;
68
69/// Outcome of the async live queue-wait edge ([`RedDBRuntime::redwire_queue_wait_json`],
70/// issue #919). Carries the three non-error terminal states the RedWire
71/// session maps to distinct frames, so a timeout never aliases an empty
72/// delivery and a cancellation never aliases a timeout:
73///   - `Delivered` → one `QueueEventPush` per message.
74///   - `TimedOut`  → a distinct `QueueWaitTimeout` frame.
75///   - `Cancelled` → a `StreamError` with the cancellation code.
76///
77/// A genuine runtime failure (bad queue, read error) stays an `Err` on
78/// the surrounding `RedDBResult`.
79#[derive(Debug)]
80pub(crate) enum RedwireWaitOutcome {
81    Delivered(Vec<crate::serde_json::Value>),
82    TimedOut,
83    Cancelled,
84}
85
86/// Slice C of PRD #718 — scope key for the queue wait registry.
87/// Today every connection in the process shares a single namespace;
88/// the helper exists so multi-tenant scoping (e.g. tenant id) can be
89/// threaded through later without touching every call site.
90pub(super) fn queue_wait_scope() -> String {
91    crate::runtime::impl_core::current_tenant().unwrap_or_default()
92}
93
94fn with_redwire_wait_context<T>(
95    auth_identity: &Option<(String, crate::auth::Role)>,
96    auth_principal: &Option<crate::auth::UserId>,
97    tenant: &Option<String>,
98    f: impl FnOnce() -> T,
99) -> T {
100    let previous_auth = current_auth_identity();
101    let previous_principal = super::execution_context::current_auth_principal();
102    let previous_tenant = current_tenant();
103    match tenant {
104        Some(t) => set_current_tenant(t.clone()),
105        None => clear_current_tenant(),
106    }
107    match (auth_identity, auth_principal) {
108        (Some((_, role)), Some(principal)) => set_current_auth_principal(principal.clone(), *role),
109        _ => clear_current_auth_identity(),
110    }
111    let result = f();
112    match previous_tenant {
113        Some(t) => set_current_tenant(t),
114        None => clear_current_tenant(),
115    }
116    match (previous_auth, previous_principal) {
117        (Some((_, role)), Some(principal)) => set_current_auth_principal(principal, role),
118        _ => clear_current_auth_identity(),
119    }
120    result
121}
122
123/// Convert a lifecycle `QueueSide` view into the AST flavour we accept
124/// from `QueueCommand` callers. Both enums are isomorphic but live in
125/// different modules.
126fn ast_side_to_lc(side: reddb_rql::ast::QueueSide) -> LcQueueSide {
127    use reddb_rql::ast::QueueSide as Ast;
128    match side {
129        Ast::Left => LcQueueSide::Left,
130        Ast::Right => LcQueueSide::Right,
131    }
132}
133
134/// Map a `QueueStoreError` (returned by lifecycle methods) onto the
135/// runtime-facing `RedDBError`.
136fn map_qse(err: QueueStoreError) -> RedDBError {
137    match err {
138        QueueStoreError::UnknownDelivery(id) => RedDBError::NotFound(format!(
139            "delivery_id '{id}' does not resolve to a live pending delivery"
140        )),
141        QueueStoreError::UnknownQueue(q) => RedDBError::NotFound(format!("queue '{q}' not found")),
142    }
143}
144
145// ---------------------------------------------------------------------------
146// Outbox metrics (exposed via /metrics)
147// ---------------------------------------------------------------------------
148
149/// Total event push attempts that failed (queue full or other error) and
150/// triggered DLQ routing.
151pub static EVENTS_DRAIN_RETRIES_TOTAL: AtomicU64 = AtomicU64::new(0);
152
153/// Total events routed to the dead-letter queue.
154pub static EVENTS_DLQ_TOTAL: AtomicU64 = AtomicU64::new(0);
155
156/// Total events successfully enqueued to their target queue.
157pub static EVENTS_ENQUEUED_TOTAL: AtomicU64 = AtomicU64::new(0);
158
159/// Warn when total estimated outbox payload bytes exceed this value (1 GiB).
160const OUTBOX_WARN_BYTES: u64 = 1 << 30;
161
162/// Route all new events to DLQ when estimated outbox exceeds this value (10 GiB).
163const OUTBOX_MAX_BYTES: u64 = 10 * (1 << 30);
164
165/// Running estimate of bytes pending in event queues (approximate; not decremented on consume).
166static OUTBOX_APPROX_BYTES: AtomicU64 = AtomicU64::new(0);
167
168pub(super) const QUEUE_META_COLLECTION: &str = "red_queue_meta";
169pub(super) const KIND_QUEUE_PUSH_DEDUP: &str = "queue_push_dedup";
170const QUEUE_POSITION_CENTER: u64 = u64::MAX / 2;
171const WORK_DEFAULT_GROUP: &str = "_work_default";
172const FANOUT_GROUP_PREFIX: &str = "_fanout_";
173
174#[derive(Debug, Clone)]
175pub(super) struct QueueRuntimeConfig {
176    pub(super) mode: QueueMode,
177    pub(super) priority: bool,
178    pub(super) max_size: Option<usize>,
179    pub(super) ttl_ms: Option<u64>,
180    pub(super) dlq: Option<String>,
181    pub(super) max_attempts: u32,
182    pub(super) lock_deadline_ms: u64,
183    pub(super) in_flight_cap_per_group: u32,
184    /// Default retry delay (issue #723) applied to NACK-requeued
185    /// messages before they become re-deliverable. `None` keeps the
186    /// pre-#723 immediate-requeue behaviour. Overridden per-failure by
187    /// an authorized `NACK ... WITH DELAY <duration>`.
188    pub(super) retry_delay_ms: Option<u64>,
189    /// Queue-scoped push idempotency window. `None` means use the engine
190    /// default from the RQL AST crate.
191    pub(super) dedup_window_ms: Option<u64>,
192}
193
194#[derive(Debug, Clone)]
195struct QueueGroupEntry {
196    entity_id: EntityId,
197    group: String,
198}
199
200#[derive(Debug, Clone)]
201pub(super) struct QueuePendingEntry {
202    pub(super) entity_id: EntityId,
203    group: String,
204    pub(super) message_id: EntityId,
205    consumer: String,
206    pub(super) delivered_at_ns: u64,
207    pub(super) delivery_count: u32,
208}
209
210#[derive(Debug, Clone)]
211pub(super) struct QueueAckEntry {
212    entity_id: EntityId,
213    group: String,
214    pub(super) message_id: EntityId,
215}
216
217#[derive(Debug, Clone)]
218pub(super) struct QueueMessageView {
219    pub(super) id: EntityId,
220    position: u64,
221    priority: i32,
222    pub(super) payload: Value,
223    attempts: u32,
224    pub(super) max_attempts: u32,
225    enqueued_at_ns: u64,
226    /// First-delivery instant for delayed messages (issue #722). `None`
227    /// means immediate availability. Sourced from the
228    /// `_available_at_ns` metadata field, populated on push.
229    pub(super) available_at_ns: Option<u64>,
230    /// Optional grouped-delivery ordering key, sourced from message metadata.
231    pub(super) ordering_key: Option<String>,
232}
233
234impl QueueMessageView {
235    /// Whether this message is currently deliverable. Messages whose
236    /// `available_at_ns` lies in the future remain durable and
237    /// inspectable but are filtered out of `QUEUE READ` / `QUEUE POP`
238    /// projections.
239    pub(super) fn is_available_now(&self) -> bool {
240        match self.available_at_ns {
241            Some(at) => at <= now_ns(),
242            None => true,
243        }
244    }
245}
246
247impl RedDBRuntime {
248    /// Slice C of PRD #718 — non-blocking `group_read` plus optional
249    /// `WAIT <duration>` retry. When `wait_ms` is `None` this is the
250    /// pre-slice-C synchronous read. When `Some`, an immediate empty
251    /// projection parks the caller on the shared
252    /// [`crate::runtime::queue_wait_registry::QueueWaitRegistry`] and
253    /// retries on wake until the deadline. Timeout returns an empty
254    /// projection (zero records, no error). Shutdown cancellation
255    /// returns [`QUEUE_READ_WAIT_CANCELLED`].
256    pub(super) fn group_read_with_optional_wait(
257        &self,
258        queue: &str,
259        group: &str,
260        consumer: &str,
261        count: usize,
262        wait_ms: Option<u64>,
263    ) -> RedDBResult<Vec<crate::runtime::queue_lifecycle::DeliveredMessage>> {
264        let do_read =
265            |runtime: &RedDBRuntime| -> RedDBResult<Vec<crate::runtime::queue_lifecycle::DeliveredMessage>> {
266                // #1371 — serialize concurrent group consumers on this queue so
267                // a single available message is claimed (mark_pending) and
268                // returned by exactly one consumer. Without this, woken
269                // competing waiters all observe the message as available and
270                // each delivers it (double-delivery across the wake-all).
271                let read_lock = runtime
272                    .inner
273                    .rmw_locks
274                    .lock_for(queue, "__queue_group_read__");
275                let _read_guard = read_lock.lock();
276                // WAIT is an autocommit live subscription: each retry must see
277                // commits since the previous probe. Keep identity/tenant fixed
278                // and restore the enclosing statement snapshot after the read.
279                let _snapshot_guard = wait_ms
280                    .and_then(|_| crate::runtime::impl_core::capture_current_snapshot())
281                    .map(|mut context| {
282                        context.snapshot = context.manager.fresh_read_snapshot();
283                        crate::runtime::execution_context::CurrentSnapshotGuard::install(context)
284                    });
285                let (lifecycle, _ps, txn) = runtime_lifecycle(runtime, queue);
286                lifecycle
287                    .group_read(&txn, queue, group, consumer, count)
288                    .map_err(map_qse)
289            };
290
291        let delivered = do_read(self)?;
292        let Some(wait_ms) = wait_ms else {
293            return Ok(delivered);
294        };
295        if !delivered.is_empty() {
296            return Ok(delivered);
297        }
298        // Empty under WAIT: park on the registry. WAIT 0 collapses to
299        // a single re-probe of the registry's current state — useful
300        // for tests but the timeout path returns immediately.
301        //
302        // Telemetry (slice D / PRD #718 / #729): we record exactly one
303        // `wait_started` increment at entry, and exactly one terminal
304        // outcome increment + histogram observation at exit, for the
305        // (scope, queue) labels. The histogram measures wall-clock
306        // started→resolved across all re-park iterations of this call.
307        let registry = self.queue_wait_registry();
308        let scope = queue_wait_scope();
309        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(wait_ms);
310        let telemetry = self.queue_telemetry();
311        telemetry.record_wait_started(&scope, queue);
312        let wait_start = std::time::Instant::now();
313        let observe = |outcome: crate::runtime::queue_telemetry::WaitOutcomeLabel| {
314            let elapsed_ms = wait_start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
315            telemetry.record_wait_outcome(&scope, queue, outcome, elapsed_ms);
316        };
317        loop {
318            // Snapshot BEFORE the re-probe so a notify that fires
319            // between the probe and the park bumps the generation and
320            // wait_until returns Woken without ever blocking.
321            let snapshot = registry.snapshot(&scope, queue);
322            let delivered = do_read(self)?;
323            if !delivered.is_empty() {
324                observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Woken);
325                return Ok(delivered);
326            }
327            if registry.is_cancelled() {
328                observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Cancelled);
329                return Err(RedDBError::Query(QUEUE_READ_WAIT_CANCELLED.to_string()));
330            }
331            if std::time::Instant::now() >= deadline {
332                observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Timeout);
333                return Ok(Vec::new());
334            }
335            // Issue #722: a delayed message becomes deliverable when its
336            // `available_at_ns` passes, but the registry only wakes on
337            // producer commits — a quiet queue with a future due-time
338            // would otherwise sit on the condvar until the user budget
339            // expired. Cap the park horizon at the soonest future
340            // `available_at_ns` so the next loop iteration probes the
341            // queue at-or-just-after the message becomes due. A
342            // `Timeout` from the capped park is not the final answer; we
343            // loop and re-probe before deciding the user budget is up.
344            let park_deadline = match earliest_future_available_at(&self.inner.db.store(), queue) {
345                Some(at_ns) => {
346                    let now_ns = now_ns();
347                    if at_ns <= now_ns {
348                        // Already due; re-probe immediately.
349                        deadline.min(std::time::Instant::now())
350                    } else {
351                        let wait_ns = at_ns - now_ns;
352                        let due_instant =
353                            std::time::Instant::now() + std::time::Duration::from_nanos(wait_ns);
354                        deadline.min(due_instant)
355                    }
356                }
357                None => deadline,
358            };
359            match registry.wait_until(&snapshot, park_deadline) {
360                crate::runtime::queue_wait_registry::WaitOutcome::Woken => continue,
361                crate::runtime::queue_wait_registry::WaitOutcome::Timeout => {
362                    // If this was the user-supplied deadline, give up;
363                    // otherwise loop and re-probe (a delayed message may
364                    // have just become due).
365                    if std::time::Instant::now() >= deadline {
366                        observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Timeout);
367                        return Ok(Vec::new());
368                    }
369                    continue;
370                }
371                crate::runtime::queue_wait_registry::WaitOutcome::Cancelled => {
372                    observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Cancelled);
373                    return Err(RedDBError::Query(QUEUE_READ_WAIT_CANCELLED.to_string()));
374                }
375            }
376        }
377    }
378
379    /// Issue #917 — async live-wait edge used by the RedWire session.
380    ///
381    /// Unlike [`group_read_with_optional_wait`](Self::group_read_with_optional_wait)
382    /// (the synchronous HTTP/condvar caller), this parks on the
383    /// registry's async wake head and never holds a blocking OS thread:
384    /// the awaiting tokio worker is released back to the runtime for the
385    /// wait duration. On every wake it re-probes the *normal* queue
386    /// delivery path (`group_read`), so a delivered message is genuinely
387    /// claimed, not merely observed. Returns the delivered messages
388    /// rendered as JSON values (so the transport edge stays free of
389    /// runtime queue types); [`RedwireWaitOutcome::TimedOut`] means the
390    /// deadline elapsed without a delivery (issue #919 surfaces this as
391    /// a distinct timeout frame rather than an empty push), and a
392    /// cancellation surfaces as [`RedwireWaitOutcome::Cancelled`] — the
393    /// async analogue of the sync path's [`QUEUE_READ_WAIT_CANCELLED`].
394    /// A genuine runtime failure stays an `Err`.
395    pub(crate) async fn redwire_queue_wait_json(
396        &self,
397        queue: &str,
398        group: Option<&str>,
399        consumer: &str,
400        count: usize,
401        wait_ms: u64,
402        auth_identity: Option<(String, crate::auth::Role)>,
403        auth_principal: Option<crate::auth::UserId>,
404        tenant: Option<String>,
405    ) -> RedDBResult<RedwireWaitOutcome> {
406        let group_owned: RedDBResult<String> =
407            with_redwire_wait_context(&auth_identity, &auth_principal, &tenant, || {
408                let expr = reddb_rql::ast::QueryExpr::QueueCommand(
409                    reddb_rql::ast::QueueCommand::GroupRead {
410                        queue: queue.to_string(),
411                        group: group.map(str::to_string),
412                        consumer: consumer.to_string(),
413                        count,
414                        wait_ms: Some(wait_ms),
415                    },
416                );
417                self.check_query_privilege(&expr)
418                    .map_err(RedDBError::Query)?;
419                let store = self.inner.db.store();
420                ensure_queue_exists(store.as_ref(), queue)?;
421                let config = load_queue_config(store.as_ref(), queue);
422                resolve_read_group(store.as_ref(), queue, group, consumer, &config)
423            });
424        let group_owned = group_owned?;
425        let group_ref = group_owned.as_str();
426
427        let do_read =
428            |runtime: &RedDBRuntime| -> RedDBResult<Vec<crate::runtime::queue_lifecycle::DeliveredMessage>> {
429                with_redwire_wait_context(&auth_identity, &auth_principal, &tenant, || {
430                    let (lifecycle, _ps, txn) = runtime_lifecycle(runtime, queue);
431                    lifecycle
432                        .group_read(&txn, queue, group_ref, consumer, count)
433                        .map_err(map_qse)
434                })
435            };
436
437        let render = |delivered: Vec<crate::runtime::queue_lifecycle::DeliveredMessage>| {
438            RedwireWaitOutcome::Delivered(
439                delivered.into_iter().map(delivered_message_json).collect(),
440            )
441        };
442
443        // Fast path: a message is already deliverable at open time.
444        let delivered = do_read(self)?;
445        if !delivered.is_empty() {
446            return Ok(render(delivered));
447        }
448
449        let registry = self.queue_wait_registry();
450        let scope =
451            with_redwire_wait_context(&auth_identity, &auth_principal, &tenant, queue_wait_scope);
452        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(wait_ms);
453        let telemetry = self.queue_telemetry();
454        telemetry.record_wait_started(&scope, queue);
455        let wait_start = std::time::Instant::now();
456        tracing::debug!(
457            target: "reddb::redwire::queue_wait",
458            queue,
459            group = group_ref,
460            consumer,
461            count,
462            wait_ms,
463            scope = scope.as_str(),
464            "redwire queue wait parked"
465        );
466        let observe = |outcome: crate::runtime::queue_telemetry::WaitOutcomeLabel| {
467            let elapsed_ms = wait_start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
468            telemetry.record_wait_outcome(&scope, queue, outcome, elapsed_ms);
469            tracing::debug!(
470                target: "reddb::redwire::queue_wait",
471                queue,
472                group = group_ref,
473                consumer,
474                count,
475                wait_ms,
476                scope = scope.as_str(),
477                outcome = outcome.as_str(),
478                duration_ms = elapsed_ms,
479                "redwire queue wait resolved"
480            );
481        };
482        loop {
483            // Register the async waiter (snapshot the generation) BEFORE
484            // the re-probe so a notify landing between probe and park is
485            // observed as a generation move rather than a lost wake.
486            let waiter = registry.async_waiter(&scope, queue);
487            let delivered = do_read(self)?;
488            if !delivered.is_empty() {
489                observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Woken);
490                return Ok(render(delivered));
491            }
492            if registry.is_cancelled() {
493                observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Cancelled);
494                return Ok(RedwireWaitOutcome::Cancelled);
495            }
496            if std::time::Instant::now() >= deadline {
497                observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Timeout);
498                return Ok(RedwireWaitOutcome::TimedOut);
499            }
500            let park_deadline = match earliest_future_available_at(&self.inner.db.store(), queue) {
501                Some(at_ns) => {
502                    let now_ns = now_ns();
503                    if at_ns <= now_ns {
504                        deadline.min(std::time::Instant::now())
505                    } else {
506                        let wait_ns = at_ns - now_ns;
507                        let due_instant =
508                            std::time::Instant::now() + std::time::Duration::from_nanos(wait_ns);
509                        deadline.min(due_instant)
510                    }
511                }
512                None => deadline,
513            };
514            // The async waiter (and its `Arc<Slot>` clone) is a local
515            // dropped on every return below, so an expired or cancelled
516            // wait releases its registry slot reference and frees the
517            // tokio worker the moment this future resolves (AC #4).
518            match registry.wait_until_async(&waiter, park_deadline).await {
519                crate::runtime::queue_wait_registry::WaitOutcome::Woken => continue,
520                crate::runtime::queue_wait_registry::WaitOutcome::Timeout => {
521                    if std::time::Instant::now() >= deadline {
522                        observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Timeout);
523                        return Ok(RedwireWaitOutcome::TimedOut);
524                    }
525                    continue;
526                }
527                crate::runtime::queue_wait_registry::WaitOutcome::Cancelled => {
528                    observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Cancelled);
529                    return Ok(RedwireWaitOutcome::Cancelled);
530                }
531            }
532        }
533    }
534
535    /// Reject a live queue-wait open whose requested budget exceeds the
536    /// server's maximum wait cap (issue #919), mirroring the SQL
537    /// `QUEUE READ … WAIT` cap (slice B of PRD #718). Returns the
538    /// operator-actionable message (naming the `red.config` key and the
539    /// active cap) when `wait_ms` is over the cap, or `Ok(())` to
540    /// proceed. The transport calls this *before* spawning the wait
541    /// task, so an over-cap request is refused with an explicit error
542    /// and never parks — not silently shortened (AC #3).
543    pub(crate) fn redwire_queue_wait_cap_check(&self, wait_ms: u64) -> Result<(), String> {
544        let cap = self.config_u64(QUEUE_MAX_WAIT_MS_CONFIG_KEY, QUEUE_MAX_WAIT_MS_DEFAULT);
545        if wait_ms > cap {
546            Err(format!(
547                "queue-wait WAIT {wait_ms}ms exceeds server cap {QUEUE_MAX_WAIT_MS_CONFIG_KEY} = {cap}ms"
548            ))
549        } else {
550            Ok(())
551        }
552    }
553
554    pub(crate) fn enqueue_event_payload(
555        &self,
556        queue: &str,
557        payload: Value,
558    ) -> RedDBResult<EntityId> {
559        let _event_snapshot = super::delete_publication::event_context(self)
560            .map(super::execution_context::CurrentSnapshotGuard::install);
561        let store = self.inner.db.store();
562        // Auto-create the queue if it does not exist yet.
563        if store.get_collection(queue).is_none() {
564            crate::runtime::impl_ddl::ensure_event_target_queue_pub(self, queue)?;
565        }
566
567        // Estimate payload bytes for outbox watermark checks.
568        let payload_bytes = estimate_payload_bytes(&payload);
569        let outbox_bytes = OUTBOX_APPROX_BYTES.fetch_add(payload_bytes, Ordering::Relaxed);
570
571        // Hard limit: route directly to DLQ without even trying.
572        if outbox_bytes > OUTBOX_MAX_BYTES {
573            OUTBOX_APPROX_BYTES.fetch_sub(payload_bytes, Ordering::Relaxed);
574            EVENTS_DRAIN_RETRIES_TOTAL.fetch_add(1, Ordering::Relaxed);
575            return self.route_event_to_outbox_dlq(queue, payload, "outbox_max_bytes_exceeded");
576        }
577
578        // Soft limit: warn once per crossing.
579        if outbox_bytes > OUTBOX_WARN_BYTES && outbox_bytes - payload_bytes <= OUTBOX_WARN_BYTES {
580            tracing::warn!(
581                outbox_bytes,
582                warn_threshold = OUTBOX_WARN_BYTES,
583                "event outbox approaching capacity warning threshold"
584            );
585            crate::telemetry::operator_event::OperatorEvent::OutboxDlqActivated {
586                queue: queue.to_string(),
587                dlq: format!("{queue}_outbox_dlq"),
588                reason: "outbox_warn_bytes_exceeded".to_string(),
589            }
590            .emit_global();
591        }
592
593        let config = load_queue_config(store.as_ref(), queue);
594
595        // If the target queue has a max_size and is full, route to DLQ.
596        if let Some(max_size) = config.max_size {
597            let current_len = load_queue_message_views(store.as_ref(), queue)
598                .unwrap_or_default()
599                .len();
600            if current_len >= max_size {
601                OUTBOX_APPROX_BYTES.fetch_sub(payload_bytes, Ordering::Relaxed);
602                EVENTS_DRAIN_RETRIES_TOTAL.fetch_add(1, Ordering::Relaxed);
603                return self.route_event_to_outbox_dlq(queue, payload, "queue_full");
604            }
605            // Warn at 80% capacity.
606            if current_len * 10 >= max_size * 8 {
607                tracing::warn!(
608                    queue = %queue,
609                    size = current_len,
610                    max = max_size,
611                    "event target queue near capacity"
612                );
613            }
614        }
615
616        let id = self.enqueue_event_payload_raw(store.as_ref(), queue, &config, payload)?;
617        EVENTS_ENQUEUED_TOTAL.fetch_add(1, Ordering::Relaxed);
618        Ok(id)
619    }
620
621    /// Route a failed event to `<queue>_outbox_dlq`, auto-creating it if needed.
622    fn route_event_to_outbox_dlq(
623        &self,
624        queue: &str,
625        payload: Value,
626        reason: &str,
627    ) -> RedDBResult<EntityId> {
628        let dlq_name = format!("{queue}_outbox_dlq");
629        EVENTS_DLQ_TOTAL.fetch_add(1, Ordering::Relaxed);
630
631        crate::telemetry::operator_event::OperatorEvent::OutboxDlqActivated {
632            queue: queue.to_string(),
633            dlq: dlq_name.clone(),
634            reason: reason.to_string(),
635        }
636        .emit_global();
637
638        let store = self.inner.db.store();
639        if store.get_collection(&dlq_name).is_none() {
640            crate::runtime::impl_ddl::ensure_event_target_queue_pub(self, &dlq_name)?;
641        }
642        let dlq_config = load_queue_config(store.as_ref(), &dlq_name);
643        let id = self.enqueue_event_payload_raw(store.as_ref(), &dlq_name, &dlq_config, payload)?;
644        EVENTS_ENQUEUED_TOTAL.fetch_add(1, Ordering::Relaxed);
645        Ok(id)
646    }
647
648    /// Low-level event message insert — no size checks, no DLQ routing.
649    fn enqueue_event_payload_raw(
650        &self,
651        store: &UnifiedStore,
652        queue: &str,
653        config: &QueueRuntimeConfig,
654        payload: Value,
655    ) -> RedDBResult<EntityId> {
656        let event_xid = super::delete_publication::event_writer(self);
657        let position = next_queue_position(store, queue, QueueSide::Right)?;
658        let mut entity = UnifiedEntity::new(
659            EntityId::new(0),
660            EntityKind::QueueMessage {
661                queue: queue.to_string(),
662                position,
663            },
664            EntityData::QueueMessage(QueueMessageData {
665                payload,
666                priority: None,
667                enqueued_at_ns: now_ns(),
668                attempts: 0,
669                max_attempts: config.max_attempts,
670                acked: false,
671            }),
672        );
673        if let Some(xid) = self.current_xid().or(event_xid) {
674            entity.set_xmin(xid);
675        }
676        // Keep the resident message and transient WAL copies inside the same
677        // engine budget as the source mutation, including outbox/DLQ writers.
678        let bytes = crate::storage::unified::memory_size::entity_bytes(&entity) as u64;
679        let _admission = self.admit_non_evictable_growth(
680            crate::storage::memory_pools::MemoryPool::SegmentArena,
681            "enqueue event",
682            bytes.saturating_mul(3),
683        )?;
684        let id = store
685            .insert_auto(queue, entity)
686            .map_err(|err| RedDBError::Internal(err.to_string()))?;
687        if let Some(ttl_ms) = config.ttl_ms {
688            store
689                .set_metadata(queue, id, queue_message_ttl_metadata(ttl_ms))
690                .map_err(|err| RedDBError::Internal(err.to_string()))?;
691        }
692        self.invalidate_result_cache_for_table(queue);
693        Ok(id)
694    }
695
696    pub fn execute_create_queue(
697        &self,
698        raw_query: &str,
699        query: &CreateQueueQuery,
700    ) -> RedDBResult<RuntimeQueryResult> {
701        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
702        if super::impl_ddl::is_system_schema_name(&query.name) {
703            return Err(RedDBError::Query("system schema is read-only".to_string()));
704        }
705        if query.dlq.as_deref() == Some(query.name.as_str()) {
706            return Err(RedDBError::Query(
707                "dead-letter queue must be different from the source queue".to_string(),
708            ));
709        }
710
711        let store = self.inner.db.store();
712        let exists = store.get_collection(&query.name).is_some();
713        if exists {
714            if query.if_not_exists {
715                return Ok(RuntimeQueryResult::ok_message(
716                    raw_query.to_string(),
717                    &format!("queue '{}' already exists", query.name),
718                    "create",
719                ));
720            }
721            return Err(RedDBError::Query(format!(
722                "queue '{}' already exists",
723                query.name
724            )));
725        }
726
727        store
728            .create_collection(&query.name)
729            .map_err(|err| RedDBError::Internal(err.to_string()))?;
730        super::queue_storage_index::prepare_queue(self, &query.name)?;
731        if let Some(ttl_ms) = query.ttl_ms {
732            self.inner
733                .db
734                .set_collection_default_ttl_ms(&query.name, ttl_ms);
735        }
736        self.inner
737            .db
738            .save_collection_contract(queue_collection_contract(
739                &query.name,
740                query.priority,
741                query.ttl_ms,
742            ))
743            .map_err(|err| RedDBError::Internal(err.to_string()))?;
744        save_queue_config(
745            store.as_ref(),
746            &query.name,
747            &QueueRuntimeConfig {
748                mode: query.mode,
749                priority: query.priority,
750                max_size: query.max_size,
751                ttl_ms: query.ttl_ms,
752                dlq: query.dlq.clone(),
753                max_attempts: query.max_attempts,
754                lock_deadline_ms: query.lock_deadline_ms,
755                in_flight_cap_per_group: query.in_flight_cap_per_group,
756                retry_delay_ms: query.retry_delay_ms,
757                dedup_window_ms: query.dedup_window_ms,
758            },
759        )?;
760
761        if let Some(dlq) = &query.dlq {
762            if store.get_collection(dlq).is_none() {
763                store
764                    .create_collection(dlq)
765                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
766                super::queue_storage_index::prepare_queue(self, dlq)?;
767                self.inner
768                    .db
769                    .save_collection_contract(queue_collection_contract(dlq, false, None))
770                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
771            }
772        }
773
774        self.invalidate_result_cache();
775        self.inner
776            .db
777            .persist_metadata()
778            .map_err(|err| RedDBError::Internal(err.to_string()))?;
779        // Issue #120 — feed the queue into the schema-vocabulary so
780        // AskPipeline (#121) can resolve queue references. Queues
781        // have an opaque payload column, so we expose `payload` and
782        // (when configured) the DLQ partner as type-tag context.
783        let mut type_tags = Vec::new();
784        if let Some(dlq) = &query.dlq {
785            type_tags.push(format!("dlq:{}", dlq));
786        }
787        self.schema_vocabulary_apply(
788            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
789                collection: query.name.clone(),
790                columns: vec!["payload".to_string()],
791                type_tags,
792                description: None,
793            },
794        );
795
796        let mut msg = format!("queue '{}' created", query.name);
797        msg.push_str(&format!(" (mode={})", query.mode.as_str()));
798        if query.priority {
799            msg.push_str(" (priority)");
800        }
801        if let Some(max_size) = query.max_size {
802            msg.push_str(&format!(" (max_size={max_size})"));
803        }
804        if let Some(ttl_ms) = query.ttl_ms {
805            msg.push_str(&format!(" (ttl={ttl_ms}ms)"));
806        }
807        if let Some(dlq) = &query.dlq {
808            msg.push_str(&format!(
809                " (dlq={dlq}, max_attempts={})",
810                query.max_attempts
811            ));
812        }
813
814        Ok(RuntimeQueryResult::ok_message(
815            raw_query.to_string(),
816            &msg,
817            "create",
818        ))
819    }
820
821    pub fn execute_alter_queue(
822        &self,
823        raw_query: &str,
824        query: &AlterQueueQuery,
825    ) -> RedDBResult<RuntimeQueryResult> {
826        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
827        let store = self.inner.db.store();
828        ensure_queue_exists(store.as_ref(), &query.name)?;
829
830        let mut config = load_queue_config(store.as_ref(), &query.name);
831        let mut summary: Vec<String> = Vec::new();
832
833        if let Some(new_mode) = query.mode {
834            let pending =
835                load_pending_entries(store.as_ref(), &query.name, None, None).unwrap_or_default();
836            if !pending.is_empty() {
837                tracing::warn!(
838                    queue = %query.name,
839                    pending_count = pending.len(),
840                    new_mode = %new_mode.as_str(),
841                    "ALTER QUEUE SET MODE: {} in-flight messages will drain with old mode; \
842                     new reads use {}",
843                    pending.len(),
844                    new_mode.as_str(),
845                );
846            }
847            config.mode = new_mode;
848            summary.push(format!("mode={}", new_mode.as_str()));
849        }
850        if let Some(max_attempts) = query.max_attempts {
851            config.max_attempts = max_attempts;
852            summary.push(format!("max_attempts={max_attempts}"));
853        }
854        if let Some(lock_deadline_ms) = query.lock_deadline_ms {
855            config.lock_deadline_ms = lock_deadline_ms;
856            summary.push(format!("lock_deadline_ms={lock_deadline_ms}"));
857        }
858        if let Some(in_flight_cap) = query.in_flight_cap_per_group {
859            config.in_flight_cap_per_group = in_flight_cap;
860            summary.push(format!("in_flight_cap_per_group={in_flight_cap}"));
861        }
862        if let Some(dlq) = &query.dlq {
863            if dlq == &query.name {
864                return Err(RedDBError::Query(
865                    "dead-letter queue must be different from the source queue".to_string(),
866                ));
867            }
868            config.dlq = Some(dlq.clone());
869            summary.push(format!("dlq={dlq}"));
870        }
871        if let Some(retry_delay_ms) = query.retry_delay_ms {
872            config.retry_delay_ms = if retry_delay_ms == 0 {
873                None
874            } else {
875                Some(retry_delay_ms)
876            };
877            summary.push(format!("retry_delay_ms={retry_delay_ms}"));
878        }
879        if let Some(dedup_window_ms) = query.dedup_window_ms {
880            config.dedup_window_ms = Some(dedup_window_ms);
881            summary.push(format!("dedup_window_ms={dedup_window_ms}"));
882        }
883
884        save_queue_config(store.as_ref(), &query.name, &config)?;
885
886        self.invalidate_result_cache();
887        self.inner
888            .db
889            .persist_metadata()
890            .map_err(|err| RedDBError::Internal(err.to_string()))?;
891
892        Ok(RuntimeQueryResult::ok_message(
893            raw_query.to_string(),
894            &format!("queue '{}' altered: {}", query.name, summary.join(", ")),
895            "alter",
896        ))
897    }
898
899    pub fn execute_drop_queue(
900        &self,
901        raw_query: &str,
902        query: &DropQueueQuery,
903    ) -> RedDBResult<RuntimeQueryResult> {
904        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
905        let store = self.inner.db.store();
906        if super::impl_ddl::is_system_schema_name(&query.name) {
907            return Err(RedDBError::Query("system schema is read-only".to_string()));
908        }
909        if store.get_collection(&query.name).is_none() {
910            if query.if_exists {
911                return Ok(RuntimeQueryResult::ok_message(
912                    raw_query.to_string(),
913                    &format!("queue '{}' does not exist", query.name),
914                    "drop",
915                ));
916            }
917            return Err(RedDBError::NotFound(format!(
918                "queue '{}' not found",
919                query.name
920            )));
921        }
922        let actual = crate::runtime::ddl::polymorphic_resolver::resolve(
923            &query.name,
924            &self.inner.db.catalog_model_snapshot(),
925        )?;
926        crate::runtime::ddl::polymorphic_resolver::ensure_model_match(
927            crate::catalog::CollectionModel::Queue,
928            actual,
929        )?;
930
931        store
932            .drop_collection(&query.name)
933            .map_err(|err| RedDBError::Internal(err.to_string()))?;
934        self.inner.db.clear_collection_default_ttl_ms(&query.name);
935        self.inner
936            .db
937            .remove_collection_contract(&query.name)
938            .map_err(|err| RedDBError::Internal(err.to_string()))?;
939        remove_queue_metadata(store.as_ref(), &query.name);
940        self.invalidate_result_cache();
941        self.inner
942            .db
943            .persist_metadata()
944            .map_err(|err| RedDBError::Internal(err.to_string()))?;
945        // Issue #120 — invalidate the schema-vocabulary entry.
946        self.schema_vocabulary_apply(
947            crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
948                collection: query.name.clone(),
949            },
950        );
951
952        Ok(RuntimeQueryResult::ok_message(
953            raw_query.to_string(),
954            &format!("queue '{}' dropped", query.name),
955            "drop",
956        ))
957    }
958
959    pub fn execute_queue_command(
960        &self,
961        raw_query: &str,
962        cmd: &QueueCommand,
963    ) -> RedDBResult<RuntimeQueryResult> {
964        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
965        match cmd {
966            QueueCommand::Push {
967                queue,
968                value,
969                side,
970                priority,
971                key,
972                available,
973                dedup_key,
974            } => {
975                let store = self.inner.db.store();
976                ensure_queue_exists(store.as_ref(), queue)?;
977                let config = load_queue_config(store.as_ref(), queue);
978                if key.is_some() && config.priority {
979                    return Err(RedDBError::Query(format!(
980                        "ordering key is not supported on priority queue '{}'",
981                        queue
982                    )));
983                }
984                if priority.is_some() && !config.priority {
985                    return Err(RedDBError::Query(format!(
986                        "queue '{}' is not a priority queue",
987                        queue
988                    )));
989                }
990                if let Some(dedup_key) = dedup_key.as_deref() {
991                    let now = now_ns();
992                    reclaim_expired_queue_dedup(store.as_ref(), queue, now)?;
993                    if let Some(existing_id) =
994                        find_queue_dedup(store.as_ref(), queue, dedup_key, now)
995                    {
996                        let mut result = UnifiedResult::with_columns(vec![
997                            "message_id".into(),
998                            "side".into(),
999                            "queue".into(),
1000                        ]);
1001                        let mut record = UnifiedRecord::new();
1002                        record.set("message_id", Value::text(message_id_string(existing_id)));
1003                        record.set(
1004                            "side",
1005                            Value::text(match side {
1006                                QueueSide::Left => "left".to_string(),
1007                                QueueSide::Right => "right".to_string(),
1008                            }),
1009                        );
1010                        record.set("queue", Value::text(queue.clone()));
1011                        result.push(record);
1012
1013                        return Ok(RuntimeQueryResult {
1014                            query: raw_query.to_string(),
1015                            mode: QueryMode::Sql,
1016                            statement: "queue_push",
1017                            engine: "runtime-queue",
1018                            result,
1019                            affected_rows: 1,
1020                            statement_type: "insert",
1021                            bookmark: None,
1022                            notice: None,
1023                        });
1024                    }
1025                }
1026                if let Some(max_size) = config.max_size {
1027                    let current_len =
1028                        load_queue_message_views_with_runtime(Some(self), store.as_ref(), queue)?
1029                            .len();
1030                    if current_len >= max_size {
1031                        return Err(RedDBError::Query(format!(
1032                            "queue '{}' is full (max_size={max_size})",
1033                            queue
1034                        )));
1035                    }
1036                }
1037
1038                let position = next_queue_position(store.as_ref(), queue, *side)?;
1039                let mut entity = UnifiedEntity::new(
1040                    EntityId::new(0),
1041                    EntityKind::QueueMessage {
1042                        queue: queue.clone(),
1043                        position,
1044                    },
1045                    EntityData::QueueMessage(QueueMessageData {
1046                        payload: value.clone(),
1047                        priority: if config.priority { *priority } else { None },
1048                        enqueued_at_ns: now_ns(),
1049                        attempts: 0,
1050                        max_attempts: config.max_attempts,
1051                        acked: false,
1052                    }),
1053                );
1054                // Phase 1.1 MVCC universal: stamp xmin so other
1055                // connections don't see this message until COMMIT.
1056                if let Some(xid) = self.current_xid() {
1057                    entity.set_xmin(xid);
1058                }
1059                // As for event inserts, reserve resident/WAL payload overlap
1060                // before publishing either the message or its metadata.
1061                let bytes = crate::storage::unified::memory_size::entity_bytes(&entity) as u64;
1062                let _admission = self.admit_non_evictable_growth(
1063                    crate::storage::memory_pools::MemoryPool::SegmentArena,
1064                    "queue push",
1065                    bytes.saturating_mul(3),
1066                )?;
1067                let id = store
1068                    .insert_auto(queue, entity)
1069                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
1070                if let Some(dedup_key) = dedup_key.as_deref() {
1071                    let window_ms = config
1072                        .dedup_window_ms
1073                        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_DEDUP_WINDOW_MS);
1074                    let expires_at_ns =
1075                        now_ns().saturating_add(window_ms.saturating_mul(1_000_000));
1076                    let metadata_id =
1077                        record_queue_dedup(store.as_ref(), queue, dedup_key, id, expires_at_ns)?;
1078                    self.stamp_xmin_if_in_txn(QUEUE_META_COLLECTION, metadata_id);
1079                    if self.current_xid().is_some() {
1080                        self.record_pending_queue_dedup(
1081                            current_connection_id(),
1082                            queue,
1083                            dedup_key,
1084                            metadata_id,
1085                        );
1086                    }
1087                }
1088                // Resolve per-message availability (issue #722): DELAY is
1089                // relative to the push instant, AVAILABLE AT carries an
1090                // absolute unix-ms. Both collapse to a unix-ns timestamp
1091                // delivery paths compare against. `None` means immediate.
1092                let available_at_ns = available.map(|a| match a {
1093                    reddb_rql::ast::QueueAvailability::DelayMs(ms) => {
1094                        now_ns().saturating_add(ms.saturating_mul(1_000_000))
1095                    }
1096                    reddb_rql::ast::QueueAvailability::AtUnixMs(ms) => ms.saturating_mul(1_000_000),
1097                });
1098                if config.ttl_ms.is_some() || available_at_ns.is_some() || key.is_some() {
1099                    store
1100                        .set_metadata(
1101                            queue,
1102                            id,
1103                            queue_message_metadata(config.ttl_ms, available_at_ns, key.as_deref()),
1104                        )
1105                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
1106                }
1107                // Slice C of PRD #718 — wake `QUEUE READ … WAIT` waiters.
1108                // Under autocommit this fires immediately; inside a txn
1109                // the wake is buffered and replayed on COMMIT (rollback
1110                // discards it so rolled-back enqueues do not deliver).
1111                self.record_queue_wake(&queue_wait_scope(), queue);
1112                self.invalidate_result_cache();
1113
1114                let mut result = UnifiedResult::with_columns(vec![
1115                    "message_id".into(),
1116                    "side".into(),
1117                    "queue".into(),
1118                ]);
1119                let mut record = UnifiedRecord::new();
1120                record.set("message_id", Value::text(message_id_string(id)));
1121                record.set(
1122                    "side",
1123                    Value::text(match side {
1124                        QueueSide::Left => "left".to_string(),
1125                        QueueSide::Right => "right".to_string(),
1126                    }),
1127                );
1128                record.set("queue", Value::text(queue.clone()));
1129                result.push(record);
1130
1131                Ok(RuntimeQueryResult {
1132                    query: raw_query.to_string(),
1133                    mode: QueryMode::Sql,
1134                    statement: "queue_push",
1135                    engine: "runtime-queue",
1136                    result,
1137                    affected_rows: 1,
1138                    statement_type: "insert",
1139                    bookmark: None,
1140                    notice: None,
1141                })
1142            }
1143            QueueCommand::Pop { queue, side, count } => {
1144                let store = self.inner.db.store();
1145                ensure_queue_exists(store.as_ref(), queue)?;
1146                let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1147                let popped = lifecycle
1148                    .pop(queue, ast_side_to_lc(*side), *count, &txn)
1149                    .map_err(map_qse)?;
1150
1151                let mut result =
1152                    UnifiedResult::with_columns(vec!["message_id".into(), "payload".into()]);
1153                for (message_id, payload) in &popped {
1154                    let mut record = UnifiedRecord::new();
1155                    record.set(
1156                        "message_id",
1157                        Value::text(message_id_string(EntityId::new(*message_id))),
1158                    );
1159                    record.set("payload", payload.clone());
1160                    result.push(record);
1161                }
1162                let popped_count = popped.len() as u64;
1163                if popped_count > 0 {
1164                    self.invalidate_result_cache();
1165                }
1166
1167                Ok(RuntimeQueryResult {
1168                    query: raw_query.to_string(),
1169                    mode: QueryMode::Sql,
1170                    statement: "queue_pop",
1171                    engine: "runtime-queue",
1172                    result,
1173                    affected_rows: popped_count,
1174                    statement_type: "delete",
1175                    bookmark: None,
1176                    notice: None,
1177                })
1178            }
1179            QueueCommand::Peek { queue, count } => {
1180                let store = self.inner.db.store();
1181                ensure_queue_exists(store.as_ref(), queue)?;
1182                let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1183                let messages = lifecycle.peek(queue, *count, &txn);
1184
1185                let mut result =
1186                    UnifiedResult::with_columns(vec!["message_id".into(), "payload".into()]);
1187                for message in messages {
1188                    let mut record = UnifiedRecord::new();
1189                    record.set(
1190                        "message_id",
1191                        Value::text(message_id_string(EntityId::new(message.message_id))),
1192                    );
1193                    record.set("payload", message.payload);
1194                    result.push(record);
1195                }
1196
1197                Ok(RuntimeQueryResult {
1198                    query: raw_query.to_string(),
1199                    mode: QueryMode::Sql,
1200                    statement: "queue_peek",
1201                    engine: "runtime-queue",
1202                    result,
1203                    affected_rows: 0,
1204                    statement_type: "select",
1205                    bookmark: None,
1206                    notice: None,
1207                })
1208            }
1209            QueueCommand::Len { queue } => {
1210                let store = self.inner.db.store();
1211                ensure_queue_exists(store.as_ref(), queue)?;
1212                let count =
1213                    load_queue_message_views_with_runtime(Some(self), store.as_ref(), queue)?.len()
1214                        as u64;
1215                let mut result = UnifiedResult::with_columns(vec!["len".into()]);
1216                let mut record = UnifiedRecord::new();
1217                record.set("len", Value::UnsignedInteger(count));
1218                result.push(record);
1219
1220                Ok(RuntimeQueryResult {
1221                    query: raw_query.to_string(),
1222                    mode: QueryMode::Sql,
1223                    statement: "queue_len",
1224                    engine: "runtime-queue",
1225                    result,
1226                    affected_rows: 0,
1227                    statement_type: "select",
1228                    bookmark: None,
1229                    notice: None,
1230                })
1231            }
1232            QueueCommand::Purge { queue } => {
1233                let store = self.inner.db.store();
1234                ensure_queue_exists(store.as_ref(), queue)?;
1235                let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1236                let count = lifecycle.purge(queue, &txn).map_err(map_qse)?;
1237                if count > 0 {
1238                    self.invalidate_result_cache();
1239                }
1240
1241                Ok(RuntimeQueryResult::ok_message(
1242                    raw_query.to_string(),
1243                    &format!("{count} messages purged from queue '{queue}'"),
1244                    "delete",
1245                ))
1246            }
1247            QueueCommand::GroupCreate { queue, group } => {
1248                let store = self.inner.db.store();
1249                ensure_queue_exists(store.as_ref(), queue)?;
1250                if queue_group_exists(store.as_ref(), queue, group)? {
1251                    return Ok(RuntimeQueryResult::ok_message(
1252                        raw_query.to_string(),
1253                        &format!(
1254                            "consumer group '{}' already exists on queue '{}'",
1255                            group, queue
1256                        ),
1257                        "create",
1258                    ));
1259                }
1260                save_queue_group(store.as_ref(), queue, group)?;
1261                self.invalidate_result_cache();
1262
1263                Ok(RuntimeQueryResult::ok_message(
1264                    raw_query.to_string(),
1265                    &format!("consumer group '{}' created on queue '{}'", group, queue),
1266                    "create",
1267                ))
1268            }
1269            QueueCommand::GroupRead {
1270                queue,
1271                group,
1272                consumer,
1273                count,
1274                wait_ms,
1275            } => {
1276                let store = self.inner.db.store();
1277                ensure_queue_exists(store.as_ref(), queue)?;
1278                // Slice B of PRD #718: reject `WAIT` issued inside an
1279                // explicit transaction, and reject `WAIT > cap` before
1280                // any waiter is registered. Both checks fire before the
1281                // lifecycle is touched so a refused statement leaves
1282                // no side effects (no group auto-create, no parking).
1283                if let Some(ms) = *wait_ms {
1284                    if self.current_xid().is_some() {
1285                        return Err(RedDBError::Query(
1286                            "QUEUE READ … WAIT is autocommit-only: refusing to park inside an explicit transaction (BEGIN/COMMIT)"
1287                                .to_string(),
1288                        ));
1289                    }
1290                    let cap =
1291                        self.config_u64(QUEUE_MAX_WAIT_MS_CONFIG_KEY, QUEUE_MAX_WAIT_MS_DEFAULT);
1292                    if ms > cap {
1293                        return Err(RedDBError::Query(format!(
1294                            "QUEUE READ … WAIT {ms}ms exceeds server cap {QUEUE_MAX_WAIT_MS_CONFIG_KEY} = {cap}ms"
1295                        )));
1296                    }
1297                }
1298                // Resolve the consumer group up-front so the lifecycle
1299                // sees the same auto-created `_work_default` / fanout
1300                // group the legacy `read_messages` would have minted.
1301                let config = load_queue_config(store.as_ref(), queue);
1302                let group_owned =
1303                    resolve_read_group(store.as_ref(), queue, group.as_deref(), consumer, &config)?;
1304                let group_ref = group_owned.as_str();
1305                let delivered = self
1306                    .group_read_with_optional_wait(queue, group_ref, consumer, *count, *wait_ms)?;
1307
1308                // Issue #742 — record consumer presence on every read,
1309                // including empty returns. Heartbeat-driven aliveness
1310                // is the contract; pending deliveries don't define it.
1311                {
1312                    let lease_count = u32::try_from(delivered.len()).unwrap_or(u32::MAX);
1313                    let now_ns = std::time::SystemTime::now()
1314                        .duration_since(std::time::UNIX_EPOCH)
1315                        .map(|d| d.as_nanos() as u64)
1316                        .unwrap_or(0);
1317                    self.queue_presence().heartbeat(
1318                        queue,
1319                        group_ref,
1320                        consumer,
1321                        lease_count,
1322                        now_ns,
1323                    );
1324                }
1325
1326                let mut result = UnifiedResult::with_columns(vec![
1327                    "message_id".into(),
1328                    "payload".into(),
1329                    "consumer".into(),
1330                    "delivery_count".into(),
1331                    "attempts".into(),
1332                ]);
1333
1334                for message in delivered {
1335                    let mut record = UnifiedRecord::new();
1336                    record.set(
1337                        "message_id",
1338                        Value::text(message_id_string(EntityId::new(message.message_id))),
1339                    );
1340                    record.set("payload", message.payload);
1341                    record.set("consumer", Value::text(message.consumer));
1342                    record.set(
1343                        "delivery_count",
1344                        Value::UnsignedInteger(u64::from(message.delivery_count)),
1345                    );
1346                    record.set(
1347                        "attempts",
1348                        Value::UnsignedInteger(u64::from(message.delivery_count)),
1349                    );
1350                    result.push(record);
1351                }
1352                if !result.records.is_empty() {
1353                    self.invalidate_result_cache();
1354                }
1355
1356                Ok(RuntimeQueryResult {
1357                    query: raw_query.to_string(),
1358                    mode: QueryMode::Sql,
1359                    statement: "queue_group_read",
1360                    engine: "runtime-queue",
1361                    result,
1362                    affected_rows: 0,
1363                    statement_type: "select",
1364                    bookmark: None,
1365                    notice: None,
1366                })
1367            }
1368            QueueCommand::Pending { queue, group } => {
1369                let store = self.inner.db.store();
1370                ensure_queue_exists(store.as_ref(), queue)?;
1371                require_queue_group(store.as_ref(), queue, group)?;
1372                let mut pending = load_pending_entries(store.as_ref(), queue, Some(group), None)?;
1373                pending.sort_by_key(|entry| entry.delivered_at_ns);
1374                let current_time_ns = now_ns();
1375
1376                let mut result = UnifiedResult::with_columns(vec![
1377                    "message_id".into(),
1378                    "consumer".into(),
1379                    "delivered_at_ns".into(),
1380                    "delivery_count".into(),
1381                    "idle_ms".into(),
1382                ]);
1383                for entry in pending {
1384                    let mut record = UnifiedRecord::new();
1385                    record.set(
1386                        "message_id",
1387                        Value::text(message_id_string(entry.message_id)),
1388                    );
1389                    record.set("consumer", Value::text(entry.consumer));
1390                    record.set(
1391                        "delivered_at_ns",
1392                        Value::UnsignedInteger(entry.delivered_at_ns),
1393                    );
1394                    record.set(
1395                        "delivery_count",
1396                        Value::UnsignedInteger(u64::from(entry.delivery_count)),
1397                    );
1398                    record.set(
1399                        "idle_ms",
1400                        Value::UnsignedInteger(
1401                            current_time_ns.saturating_sub(entry.delivered_at_ns) / 1_000_000,
1402                        ),
1403                    );
1404                    result.push(record);
1405                }
1406
1407                Ok(RuntimeQueryResult {
1408                    query: raw_query.to_string(),
1409                    mode: QueryMode::Sql,
1410                    statement: "queue_pending",
1411                    engine: "runtime-queue",
1412                    result,
1413                    affected_rows: 0,
1414                    statement_type: "select",
1415                    bookmark: None,
1416                    notice: None,
1417                })
1418            }
1419            QueueCommand::Claim {
1420                queue,
1421                group,
1422                consumer,
1423                min_idle_ms,
1424            } => {
1425                let store = self.inner.db.store();
1426                ensure_queue_exists(store.as_ref(), queue)?;
1427                require_queue_group(store.as_ref(), queue, group)?;
1428                let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1429                let delivered = lifecycle
1430                    .claim_delivering(queue, consumer, *min_idle_ms, &txn)
1431                    .map_err(map_qse)?;
1432
1433                let mut result = UnifiedResult::with_columns(vec![
1434                    "message_id".into(),
1435                    "delivery_id".into(),
1436                    "payload".into(),
1437                    "consumer".into(),
1438                    "delivery_count".into(),
1439                ]);
1440
1441                for message in delivered {
1442                    let mut record = UnifiedRecord::new();
1443                    record.set(
1444                        "message_id",
1445                        Value::text(message_id_string(EntityId::new(message.message_id))),
1446                    );
1447                    record.set("delivery_id", Value::text(message.delivery_id));
1448                    record.set("payload", message.payload);
1449                    record.set("consumer", Value::text(message.consumer));
1450                    record.set(
1451                        "delivery_count",
1452                        Value::UnsignedInteger(u64::from(message.delivery_count)),
1453                    );
1454                    result.push(record);
1455                }
1456                if !result.records.is_empty() {
1457                    self.invalidate_result_cache();
1458                }
1459                let affected_rows = result.records.len() as u64;
1460
1461                Ok(RuntimeQueryResult {
1462                    query: raw_query.to_string(),
1463                    mode: QueryMode::Sql,
1464                    statement: "queue_claim",
1465                    engine: "runtime-queue",
1466                    result,
1467                    affected_rows,
1468                    statement_type: "update",
1469                    bookmark: None,
1470                    notice: None,
1471                })
1472            }
1473            QueueCommand::Ack {
1474                queue,
1475                group,
1476                message_id,
1477                delivery_id,
1478            } => {
1479                let store = self.inner.db.store();
1480                ensure_queue_exists(store.as_ref(), queue)?;
1481                let (group_owned, message_entity) = resolve_ack_nack_handle(
1482                    store.as_ref(),
1483                    queue,
1484                    group,
1485                    message_id,
1486                    delivery_id.as_deref(),
1487                )?;
1488                let group_ref = group_owned.as_str();
1489                require_queue_group(store.as_ref(), queue, group_ref)?;
1490                let (lifecycle, ps, txn) = runtime_lifecycle(self, queue);
1491                let did = match delivery_id.as_deref() {
1492                    Some(d) => d.to_string(),
1493                    None => ps
1494                        .find_pending_by_key(queue, message_entity.raw(), group_ref)
1495                        .ok_or_else(|| {
1496                            RedDBError::NotFound(format!(
1497                                "no pending delivery for message '{}' on queue '{}' (group '{}')",
1498                                message_entity.raw(),
1499                                queue,
1500                                group_ref
1501                            ))
1502                        })?,
1503                };
1504                lifecycle.ack(&txn, &did).map_err(map_qse)?;
1505                self.invalidate_result_cache();
1506
1507                Ok(RuntimeQueryResult::ok_message(
1508                    raw_query.to_string(),
1509                    "message acknowledged",
1510                    "update",
1511                ))
1512            }
1513            QueueCommand::Nack {
1514                queue,
1515                group,
1516                message_id,
1517                delivery_id,
1518                delay_ms,
1519            } => {
1520                let store = self.inner.db.store();
1521                ensure_queue_exists(store.as_ref(), queue)?;
1522                let config = load_queue_config(store.as_ref(), queue);
1523                // Issue #723: a per-failure DELAY override is a write
1524                // operation that re-shapes retry behavior; readers must
1525                // not be able to silently re-schedule another worker's
1526                // work. Embedded callers (no auth identity attached)
1527                // are trusted and bypass the check.
1528                if delay_ms.is_some() {
1529                    if let Some((_, role)) = current_auth_identity() {
1530                        if !role.can_write() {
1531                            return Err(RedDBError::InvalidOperation(format!(
1532                                "role '{role}' is not authorized to override NACK retry delay on queue '{queue}'"
1533                            )));
1534                        }
1535                    }
1536                }
1537                let (group_owned, message_entity) = resolve_ack_nack_handle(
1538                    store.as_ref(),
1539                    queue,
1540                    group,
1541                    message_id,
1542                    delivery_id.as_deref(),
1543                )?;
1544                let group_ref = group_owned.as_str();
1545                require_queue_group(store.as_ref(), queue, group_ref)?;
1546                let (lifecycle, ps, txn) = runtime_lifecycle(self, queue);
1547                let did = match delivery_id.as_deref() {
1548                    Some(d) => d.to_string(),
1549                    None => ps
1550                        .find_pending_by_key(queue, message_entity.raw(), group_ref)
1551                        .ok_or_else(|| {
1552                            RedDBError::NotFound(format!(
1553                                "no pending delivery for message '{}' on queue '{}' (group '{}')",
1554                                message_entity.raw(),
1555                                queue,
1556                                group_ref
1557                            ))
1558                        })?,
1559                };
1560                // Resolve the effective retry delay: per-failure
1561                // override wins, then queue default, then zero
1562                // (immediate requeue — pre-#723 behavior).
1563                let effective_delay_ms = delay_ms.or(config.retry_delay_ms).unwrap_or(0);
1564                let pending_attempt = ps.read_pending_attempt(&did).map_err(map_qse)?;
1565                let nack_attempts = pending_attempt.attempts.saturating_add(1);
1566                let retry_available_at_ns = if effective_delay_ms > 0 {
1567                    Some(now_ns().saturating_add(effective_delay_ms.saturating_mul(1_000_000)))
1568                } else {
1569                    None
1570                };
1571                let retry_deadline = if effective_delay_ms > 0 {
1572                    Some(
1573                        std::time::Instant::now()
1574                            + std::time::Duration::from_millis(effective_delay_ms),
1575                    )
1576                } else {
1577                    None
1578                };
1579                let outcome = lifecycle
1580                    .nack_with_retry_deadline(&txn, &did, retry_deadline)
1581                    .map_err(map_qse)?;
1582                // Apply delay only when the message was actually
1583                // requeued — DLQ promotion / drop terminate the
1584                // retry cycle and a delay would be meaningless.
1585                if matches!(outcome, RetirementOutcome::Requeued) && effective_delay_ms > 0 {
1586                    set_message_available_at_ns(
1587                        store.as_ref(),
1588                        queue,
1589                        message_entity,
1590                        retry_available_at_ns,
1591                        config.ttl_ms,
1592                    )?;
1593                }
1594                // Issue #723: routine retries do not flood audit
1595                // channels (telemetry already covers them via
1596                // `queue_nacked_total{outcome=...}`). Significant
1597                // overrides — large delays, destination changes,
1598                // drops — are audited so operators see the events
1599                // that re-shape operational risk.
1600                self.maybe_emit_nack_audit(
1601                    queue,
1602                    group_ref,
1603                    &did,
1604                    *delay_ms,
1605                    config.retry_delay_ms,
1606                    &outcome,
1607                );
1608                let outcome_label = match &outcome {
1609                    RetirementOutcome::Requeued => NackOutcomeLabel::Retry,
1610                    RetirementOutcome::MovedToDlq(_) => NackOutcomeLabel::Dlq,
1611                    RetirementOutcome::Dropped => NackOutcomeLabel::Drop,
1612                };
1613                self.queue_telemetry().record_nacked(
1614                    queue,
1615                    group_ref,
1616                    config.mode.as_str(),
1617                    outcome_label,
1618                );
1619                if let RetirementOutcome::MovedToDlq(dlq) = &outcome {
1620                    OperatorEvent::QueueDlqPromoted {
1621                        queue: queue.to_string(),
1622                        group: group_ref.to_string(),
1623                        dlq: dlq.clone(),
1624                        message_id: pending_attempt.message_id,
1625                        attempts: nack_attempts,
1626                        reason: format!("lifecycle_nack:{did}"),
1627                    }
1628                    .emit(self.audit_log());
1629                }
1630                let message = match outcome {
1631                    RetirementOutcome::Requeued => {
1632                        if effective_delay_ms > 0 {
1633                            format!("message requeued (delay={effective_delay_ms}ms)")
1634                        } else {
1635                            "message requeued".to_string()
1636                        }
1637                    }
1638                    RetirementOutcome::MovedToDlq(dlq) => {
1639                        format!("message moved to dead-letter queue '{}'", dlq)
1640                    }
1641                    RetirementOutcome::Dropped => "message dropped after max attempts".to_string(),
1642                };
1643                self.invalidate_result_cache();
1644
1645                Ok(RuntimeQueryResult::ok_message(
1646                    raw_query.to_string(),
1647                    &message,
1648                    "update",
1649                ))
1650            }
1651            QueueCommand::Move {
1652                source,
1653                destination,
1654                filter,
1655                limit,
1656            } => self.execute_queue_move(raw_query, source, destination, filter.as_ref(), *limit),
1657        }
1658    }
1659
1660    pub fn execute_queue_select(
1661        &self,
1662        raw_query: &str,
1663        query: &QueueSelectQuery,
1664    ) -> RedDBResult<RuntimeQueryResult> {
1665        let store = self.inner.db.store();
1666        ensure_queue_exists(store.as_ref(), &query.queue)?;
1667        let config = load_queue_config(store.as_ref(), &query.queue);
1668        let dlq = queue_is_dead_letter_target(store.as_ref(), &query.queue);
1669        let columns = if query.columns.is_empty() {
1670            queue_projection_default_columns()
1671        } else {
1672            query.columns.clone()
1673        };
1674
1675        let mut messages =
1676            load_queue_message_views_with_runtime(Some(self), store.as_ref(), &query.queue)?;
1677        sort_queue_messages(&mut messages, &config, QueueSide::Left);
1678
1679        let mut result = UnifiedResult::with_columns(columns.clone());
1680        for message in messages {
1681            if query
1682                .filter
1683                .as_ref()
1684                .is_some_and(|filter| !queue_message_matches_filter(&message, dlq, filter))
1685            {
1686                continue;
1687            }
1688            let record = queue_projection_record(&columns, &message, dlq)?;
1689            result.push(record);
1690            if query
1691                .limit
1692                .is_some_and(|limit| result.records.len() >= limit as usize)
1693            {
1694                break;
1695            }
1696        }
1697
1698        Ok(RuntimeQueryResult {
1699            query: raw_query.to_string(),
1700            mode: QueryMode::Sql,
1701            statement: "queue_select",
1702            engine: "runtime-queue",
1703            result,
1704            affected_rows: 0,
1705            statement_type: "select",
1706            bookmark: None,
1707            notice: None,
1708        })
1709    }
1710
1711    fn execute_queue_move(
1712        &self,
1713        raw_query: &str,
1714        source: &str,
1715        destination: &str,
1716        filter: Option<&Filter>,
1717        limit: usize,
1718    ) -> RedDBResult<RuntimeQueryResult> {
1719        if source == destination {
1720            return Err(RedDBError::Query(
1721                "QUEUE MOVE source and destination must be different".to_string(),
1722            ));
1723        }
1724        let store = self.inner.db.store();
1725        ensure_queue_exists(store.as_ref(), source)?;
1726        ensure_queue_exists(store.as_ref(), destination)?;
1727        let source_config = load_queue_config(store.as_ref(), source);
1728        let destination_config = load_queue_config(store.as_ref(), destination);
1729        let source_dlq = queue_is_dead_letter_target(store.as_ref(), source);
1730
1731        let mut messages =
1732            load_queue_message_views_with_runtime(Some(self), store.as_ref(), source)?;
1733        sort_queue_messages(&mut messages, &source_config, QueueSide::Left);
1734        let selected = messages
1735            .into_iter()
1736            .filter(|message| {
1737                filter
1738                    .map(|f| queue_message_matches_filter(message, source_dlq, f))
1739                    .unwrap_or(true)
1740            })
1741            .take(limit)
1742            .collect::<Vec<_>>();
1743
1744        if let Some(max_size) = destination_config.max_size {
1745            let current_len =
1746                load_queue_message_views_with_runtime(Some(self), store.as_ref(), destination)?
1747                    .len();
1748            if current_len + selected.len() > max_size {
1749                return Err(RedDBError::Query(format!(
1750                    "queue '{}' is full (max_size={max_size})",
1751                    destination
1752                )));
1753            }
1754        }
1755
1756        for message in &selected {
1757            let lock = queue_message_lock_handle(self, source, message.id);
1758            let Some(_guard) = lock.try_lock() else {
1759                return Err(RedDBError::Query(format!(
1760                    "message '{}' is locked on queue '{}'",
1761                    message.id.raw(),
1762                    source
1763                )));
1764            };
1765            if queue_message_view_by_id(store.as_ref(), source, message.id)?.is_none() {
1766                return Err(RedDBError::Query(format!(
1767                    "message '{}' is no longer available on queue '{}'",
1768                    message.id.raw(),
1769                    source
1770                )));
1771            }
1772        }
1773
1774        let mut inserted = Vec::new();
1775        for message in &selected {
1776            match insert_moved_queue_message(
1777                store.as_ref(),
1778                destination,
1779                &destination_config,
1780                message,
1781            ) {
1782                Ok(id) => inserted.push(id),
1783                Err(err) => {
1784                    for id in inserted {
1785                        let _ = store.delete(destination, id);
1786                    }
1787                    return Err(err);
1788                }
1789            }
1790        }
1791
1792        let (move_lifecycle, _move_ps, move_txn) = runtime_lifecycle(self, source);
1793        for message in &selected {
1794            move_lifecycle
1795                .delete_with_state(source, message.id.raw(), &move_txn)
1796                .map_err(map_qse)?;
1797        }
1798        if !selected.is_empty() {
1799            self.invalidate_result_cache();
1800        }
1801
1802        let selected_count = selected.len() as u64;
1803        self.audit_log().record_event(
1804            AuditEvent::builder("queue/move")
1805                .source(AuditAuthSource::System)
1806                .outcome(Outcome::Success)
1807                .resource(format!("queue:{source}->{destination}"))
1808                .fields([
1809                    AuditFieldEscaper::field("source", source),
1810                    AuditFieldEscaper::field("destination", destination),
1811                    AuditFieldEscaper::field("selected", selected_count),
1812                    AuditFieldEscaper::field("committed", selected_count),
1813                ])
1814                .build(),
1815        );
1816
1817        let mut result = UnifiedResult::with_columns(vec![
1818            "source".into(),
1819            "destination".into(),
1820            "selected".into(),
1821            "committed".into(),
1822        ]);
1823        let mut record = UnifiedRecord::new();
1824        record.set("source", Value::text(source.to_string()));
1825        record.set("destination", Value::text(destination.to_string()));
1826        record.set("selected", Value::UnsignedInteger(selected_count));
1827        record.set("committed", Value::UnsignedInteger(selected_count));
1828        result.push(record);
1829
1830        Ok(RuntimeQueryResult {
1831            query: raw_query.to_string(),
1832            mode: QueryMode::Sql,
1833            statement: "queue_move",
1834            engine: "runtime-queue",
1835            result,
1836            affected_rows: selected_count,
1837            statement_type: "update",
1838            bookmark: None,
1839            notice: None,
1840        })
1841    }
1842
1843    /// Issue #723: routine retries are observable through metrics
1844    /// (`queue_nacked_total{outcome=...}`) so this is the audit
1845    /// shoulder for the *non-routine* cases: explicit NACK delay
1846    /// overrides whose magnitude or destination-changing impact would
1847    /// be invisible in metrics alone. Specifically:
1848    ///
1849    /// - Explicit override ≥ 60s (a worker decided to defer well past
1850    ///   the queue's default cadence — operators care).
1851    /// - Override that lands on a DLQ promotion or drop (destination
1852    ///   changed; the override may have influenced retire-vs-requeue
1853    ///   accounting on the caller's side and the audit trail needs to
1854    ///   show who asked).
1855    ///
1856    /// Calls with no override are intentionally silent here.
1857    fn maybe_emit_nack_audit(
1858        &self,
1859        queue: &str,
1860        group: &str,
1861        delivery_id: &str,
1862        override_ms: Option<u64>,
1863        default_ms: Option<u64>,
1864        outcome: &RetirementOutcome,
1865    ) {
1866        let Some(override_ms) = override_ms else {
1867            return;
1868        };
1869        let outcome_label = match outcome {
1870            RetirementOutcome::Requeued => "requeued",
1871            RetirementOutcome::MovedToDlq(_) => "dlq",
1872            RetirementOutcome::Dropped => "dropped",
1873        };
1874        const SIGNIFICANT_DELAY_MS: u64 = 60_000;
1875        let destination_changed = !matches!(outcome, RetirementOutcome::Requeued);
1876        if override_ms < SIGNIFICANT_DELAY_MS && !destination_changed {
1877            return;
1878        }
1879        self.audit_log().record_event(
1880            AuditEvent::builder("queue/nack/override")
1881                .source(AuditAuthSource::System)
1882                .outcome(Outcome::Success)
1883                .resource(format!("queue:{queue}"))
1884                .fields([
1885                    AuditFieldEscaper::field("queue", queue),
1886                    AuditFieldEscaper::field("group", group),
1887                    AuditFieldEscaper::field("delivery_id", delivery_id),
1888                    AuditFieldEscaper::field("override_delay_ms", override_ms),
1889                    AuditFieldEscaper::field("default_delay_ms", default_ms.unwrap_or(0)),
1890                    AuditFieldEscaper::field("outcome", outcome_label),
1891                ])
1892                .build(),
1893        );
1894    }
1895
1896    /// Whether `collection` is declared as a queue model. Used to route a
1897    /// `CLAIM` through the [`QueueLifecycle`] seam instead of the raw
1898    /// row-update path (#1609).
1899    pub(super) fn is_queue_collection(&self, collection: &str) -> bool {
1900        self.db()
1901            .collection_contract_arc(collection)
1902            .map(|contract| contract.declared_model == crate::catalog::CollectionModel::Queue)
1903            .unwrap_or(false)
1904    }
1905
1906    /// Route a queue-collection `CLAIM` through the [`QueueLifecycle`]
1907    /// delivery seam (ADR 0020, #1609).
1908    ///
1909    /// A `CLAIM` on a queue collection is a delivery *acquisition* — select
1910    /// and lock pending messages — not a raw UPDATE of the underlying queue
1911    /// rows. Dispatching here keeps the `QueueLifecycle` state machine
1912    /// (ACK/NACK, retry, DLQ, pending delivery) the sole
1913    /// authority for delivery state, instead of `execute_update_inner_tracked`
1914    /// mutating queue storage directly.
1915    ///
1916    /// Shapes the delivery seam cannot express are rejected up front with a
1917    /// clear [`RedDBError::InvalidOperation`] rather than silently falling
1918    /// back to raw storage mutation (see [`validate_queue_claim_shape`]).
1919    pub(super) fn execute_queue_shaped_claim(
1920        &self,
1921        raw_query: &str,
1922        query: &UpdateQuery,
1923    ) -> RedDBResult<RuntimeQueryResult> {
1924        validate_queue_claim_shape(query)?;
1925
1926        let queue = query.table.as_str();
1927        let store = self.inner.db.store();
1928        ensure_queue_exists(store.as_ref(), queue)?;
1929
1930        // A queue-shaped CLAIM maps onto WORK-mode delivery: each message is
1931        // reserved for exactly one consumer. FANOUT queues fan every message
1932        // to every group, which a single-target CLAIM cannot express — those
1933        // must be consumed through GROUP READ.
1934        let config = load_queue_config(store.as_ref(), queue);
1935        if config.mode != QueueMode::Work {
1936            return Err(RedDBError::InvalidOperation(format!(
1937                "CLAIM on queue '{queue}' cannot be expressed: FANOUT delivery \
1938                 must be consumed through GROUP READ, not a queue-shaped CLAIM"
1939            )));
1940        }
1941
1942        // Attribute the delivery to the WORK default consumer group (the same
1943        // group an unqualified `QUEUE READ` uses), so the pending locks the
1944        // lifecycle records resolve for a later ACK/NACK by delivery id.
1945        let group = resolve_read_group(store.as_ref(), queue, None, "", &config)?;
1946        let count = query.claim_limit.unwrap_or(0) as usize;
1947        let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1948        let delivered = lifecycle
1949            .deliver(&txn, queue, &group, count)
1950            .map_err(map_qse)?;
1951
1952        let mut result = UnifiedResult::with_columns(vec!["delivery_id".into(), "payload".into()]);
1953        for message in &delivered {
1954            let mut record = UnifiedRecord::new();
1955            record.set("delivery_id", Value::text(message.delivery_id.clone()));
1956            record.set("payload", message.payload.clone());
1957            result.push(record);
1958        }
1959        let affected_rows = delivered.len() as u64;
1960        if affected_rows > 0 {
1961            self.invalidate_result_cache();
1962        }
1963
1964        Ok(RuntimeQueryResult {
1965            query: raw_query.to_string(),
1966            mode: QueryMode::Sql,
1967            statement: "queue_claim",
1968            engine: "runtime-queue",
1969            result,
1970            affected_rows,
1971            statement_type: "update",
1972            bookmark: None,
1973            notice: None,
1974        })
1975    }
1976}
1977
1978/// Reject `CLAIM` shapes that [`QueueLifecycle`] delivery cannot express
1979/// (#1609). Queue delivery is strictly FIFO (oldest-available first) and
1980/// acquires *up to* N available messages:
1981///
1982/// - a descending `ORDER BY` contradicts FIFO delivery order, and
1983/// - `CLAIM EXACT` demands an all-or-nothing batch the delivery seam does
1984///   not offer.
1985///
1986/// Both surface a clear [`RedDBError::InvalidOperation`] instead of a
1987/// silent raw storage mutation.
1988fn validate_queue_claim_shape(query: &UpdateQuery) -> RedDBResult<()> {
1989    if query.order_by.iter().any(|clause| !clause.ascending) {
1990        return Err(RedDBError::InvalidOperation(format!(
1991            "CLAIM on queue '{}' cannot be expressed: a descending ORDER BY \
1992             conflicts with FIFO queue delivery order",
1993            query.table
1994        )));
1995    }
1996    if query.claim_exact {
1997        return Err(RedDBError::InvalidOperation(format!(
1998            "CLAIM EXACT on queue '{}' cannot be expressed: queue delivery \
1999             acquires up to N available messages, not an exact-or-nothing batch",
2000            query.table
2001        )));
2002    }
2003    Ok(())
2004}
2005
2006fn ensure_queue_exists(store: &UnifiedStore, queue: &str) -> RedDBResult<()> {
2007    if store.get_collection(queue).is_some() {
2008        Ok(())
2009    } else {
2010        Err(RedDBError::NotFound(format!("queue '{}' not found", queue)))
2011    }
2012}
2013
2014pub(super) fn load_queue_config(store: &UnifiedStore, queue: &str) -> QueueRuntimeConfig {
2015    let default = QueueRuntimeConfig {
2016        mode: QueueMode::Work,
2017        priority: false,
2018        max_size: None,
2019        ttl_ms: None,
2020        dlq: None,
2021        max_attempts: reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS,
2022        lock_deadline_ms: reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS,
2023        in_flight_cap_per_group: reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP,
2024        retry_delay_ms: None,
2025        dedup_window_ms: None,
2026    };
2027
2028    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2029        return default;
2030    };
2031    let rows = match super::queue_storage_index::subject_rows_store(
2032        store,
2033        "queue_config",
2034        queue,
2035        None,
2036        None,
2037    ) {
2038        Ok(Some(rows)) => rows,
2039        Ok(None) | Err(_) => manager
2040            .query_all(|entity| {
2041                entity.data.as_row().is_some_and(|row| {
2042                    row_text(row, "kind").as_deref() == Some("queue_config")
2043                        && row_text(row, "queue").as_deref() == Some(queue)
2044                })
2045            })
2046            .into_iter()
2047            .filter_map(|entity| match entity.data {
2048                EntityData::Row(row) => Some((entity.id, row)),
2049                _ => None,
2050            })
2051            .collect(),
2052    };
2053    rows.into_iter()
2054        .next()
2055        .map(|(_, row)| QueueRuntimeConfig {
2056            mode: row_text(&row, "mode")
2057                .as_deref()
2058                .and_then(QueueMode::parse)
2059                .unwrap_or_default(),
2060            priority: row_bool(&row, "priority").unwrap_or(false),
2061            max_size: row_u64(&row, "max_size").map(|value| value as usize),
2062            ttl_ms: row_u64(&row, "ttl_ms"),
2063            dlq: row_text(&row, "dlq"),
2064            max_attempts: row_u64(&row, "max_attempts")
2065                .map(|value| value as u32)
2066                .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS),
2067            lock_deadline_ms: row_u64(&row, "lock_deadline_ms")
2068                .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS),
2069            in_flight_cap_per_group: row_u64(&row, "in_flight_cap_per_group")
2070                .map(|value| value as u32)
2071                .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP),
2072            retry_delay_ms: row_u64(&row, "retry_delay_ms").filter(|v| *v > 0),
2073            dedup_window_ms: row_u64(&row, "dedup_window_ms"),
2074        })
2075        .unwrap_or(default)
2076}
2077
2078pub(super) fn queue_mode_str(store: &UnifiedStore, queue: &str) -> &'static str {
2079    load_queue_config(store, queue).mode.as_str()
2080}
2081
2082fn save_queue_config(
2083    store: &UnifiedStore,
2084    queue: &str,
2085    config: &QueueRuntimeConfig,
2086) -> RedDBResult<()> {
2087    match super::queue_storage_index::subject_rows_store(store, "queue_config", queue, None, None)?
2088    {
2089        Some(rows) => {
2090            for (id, _) in rows {
2091                let _ = store.delete(QUEUE_META_COLLECTION, id);
2092            }
2093        }
2094        None => remove_meta_rows(store, |row| {
2095            row_text(row, "kind").as_deref() == Some("queue_config")
2096                && row_text(row, "queue").as_deref() == Some(queue)
2097        }),
2098    }
2099
2100    let mut fields = HashMap::new();
2101    fields.insert("kind".to_string(), Value::text("queue_config".to_string()));
2102    fields.insert("queue".to_string(), Value::text(queue.to_string()));
2103    fields.insert(
2104        "mode".to_string(),
2105        Value::text(config.mode.as_str().to_string()),
2106    );
2107    fields.insert("priority".to_string(), Value::Boolean(config.priority));
2108    fields.insert(
2109        "max_size".to_string(),
2110        config
2111            .max_size
2112            .map(|value| Value::UnsignedInteger(value as u64))
2113            .unwrap_or(Value::Null),
2114    );
2115    fields.insert(
2116        "ttl_ms".to_string(),
2117        config
2118            .ttl_ms
2119            .map(Value::UnsignedInteger)
2120            .unwrap_or(Value::Null),
2121    );
2122    fields.insert(
2123        "dlq".to_string(),
2124        config.dlq.clone().map(Value::text).unwrap_or(Value::Null),
2125    );
2126    fields.insert(
2127        "max_attempts".to_string(),
2128        Value::UnsignedInteger(u64::from(config.max_attempts)),
2129    );
2130    fields.insert(
2131        "lock_deadline_ms".to_string(),
2132        Value::UnsignedInteger(config.lock_deadline_ms),
2133    );
2134    fields.insert(
2135        "in_flight_cap_per_group".to_string(),
2136        Value::UnsignedInteger(u64::from(config.in_flight_cap_per_group)),
2137    );
2138    fields.insert(
2139        "retry_delay_ms".to_string(),
2140        config
2141            .retry_delay_ms
2142            .map(Value::UnsignedInteger)
2143            .unwrap_or(Value::Null),
2144    );
2145    fields.insert(
2146        "dedup_window_ms".to_string(),
2147        config
2148            .dedup_window_ms
2149            .map(Value::UnsignedInteger)
2150            .unwrap_or(Value::Null),
2151    );
2152    insert_meta_row(store, fields).map(|_| ())
2153}
2154
2155fn remove_queue_metadata(store: &UnifiedStore, queue: &str) {
2156    remove_meta_rows(store, |row| {
2157        row_text(row, "queue").as_deref() == Some(queue)
2158    });
2159}
2160
2161fn queue_group_exists(store: &UnifiedStore, queue: &str, group: &str) -> RedDBResult<bool> {
2162    Ok(load_queue_groups(store, queue)?
2163        .into_iter()
2164        .any(|entry| entry.group == group))
2165}
2166
2167pub(super) fn require_queue_group(
2168    store: &UnifiedStore,
2169    queue: &str,
2170    group: &str,
2171) -> RedDBResult<()> {
2172    if queue_group_exists(store, queue, group)? {
2173        Ok(())
2174    } else {
2175        Err(RedDBError::NotFound(format!(
2176            "consumer group '{}' not found on queue '{}'",
2177            group, queue
2178        )))
2179    }
2180}
2181
2182pub(super) fn resolve_read_group(
2183    store: &UnifiedStore,
2184    queue: &str,
2185    group: Option<&str>,
2186    consumer: &str,
2187    config: &QueueRuntimeConfig,
2188) -> RedDBResult<String> {
2189    if let Some(group) = group {
2190        require_queue_group(store, queue, group)?;
2191        return Ok(group.to_string());
2192    }
2193
2194    match config.mode {
2195        QueueMode::Work => {
2196            if !queue_group_exists(store, queue, WORK_DEFAULT_GROUP)? {
2197                save_queue_group(store, queue, WORK_DEFAULT_GROUP)?;
2198            }
2199            Ok(WORK_DEFAULT_GROUP.to_string())
2200        }
2201        QueueMode::Fanout => {
2202            let fanout_group = format!("{FANOUT_GROUP_PREFIX}{consumer}");
2203            if !queue_group_exists(store, queue, &fanout_group)? {
2204                save_queue_group(store, queue, &fanout_group)?;
2205            }
2206            Ok(fanout_group)
2207        }
2208    }
2209}
2210
2211fn load_queue_groups(store: &UnifiedStore, queue: &str) -> RedDBResult<Vec<QueueGroupEntry>> {
2212    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2213        return Ok(Vec::new());
2214    };
2215    if let Some(rows) =
2216        super::queue_storage_index::subject_rows_store(store, "queue_group", queue, None, None)?
2217    {
2218        return Ok(rows
2219            .into_iter()
2220            .filter_map(|(entity_id, row)| {
2221                Some(QueueGroupEntry {
2222                    entity_id,
2223                    group: row_text(&row, "group")?,
2224                })
2225            })
2226            .collect());
2227    }
2228    let _publication = manager
2229        .has_backing()
2230        .then(|| store.vault_publication_lock.lock());
2231    let mut groups = Vec::new();
2232    super::function_budget::try_scan(
2233        |visit| manager.try_for_each_entity(usize::MAX, visit),
2234        |entity| {
2235            let Some(row) = entity.data.as_row() else {
2236                return true;
2237            };
2238            if row_text(row, "kind").as_deref() == Some("queue_group")
2239                && row_text(row, "queue").as_deref() == Some(queue)
2240            {
2241                let Some(group) = row_text(row, "group") else {
2242                    return true;
2243                };
2244                groups.push(QueueGroupEntry {
2245                    entity_id: entity.id,
2246                    group,
2247                });
2248            }
2249            true
2250        },
2251    )?;
2252    Ok(groups)
2253}
2254
2255fn save_queue_group(store: &UnifiedStore, queue: &str, group: &str) -> RedDBResult<()> {
2256    let mut fields = HashMap::new();
2257    fields.insert("kind".to_string(), Value::text("queue_group".to_string()));
2258    fields.insert("queue".to_string(), Value::text(queue.to_string()));
2259    fields.insert("group".to_string(), Value::text(group.to_string()));
2260    fields.insert(
2261        "created_at_ns".to_string(),
2262        Value::UnsignedInteger(now_ns()),
2263    );
2264    insert_meta_row(store, fields).map(|_| ())
2265}
2266
2267pub(super) fn load_pending_entries(
2268    store: &UnifiedStore,
2269    queue: &str,
2270    group: Option<&str>,
2271    message_id: Option<EntityId>,
2272) -> RedDBResult<Vec<QueuePendingEntry>> {
2273    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2274        return Ok(Vec::new());
2275    };
2276    let lock_deadline_ns = load_queue_config(store, queue)
2277        .lock_deadline_ms
2278        .saturating_mul(1_000_000);
2279    let indexed_attempts = super::queue_storage_index::subject_rows_store(
2280        store,
2281        "queue_attempts_lc",
2282        queue,
2283        group,
2284        message_id.map(|id| id.raw()),
2285    )?;
2286    let attempts = indexed_attempts.unwrap_or_else(|| {
2287        manager
2288            .query_all(|entity| {
2289                entity.data.as_row().is_some_and(|row| {
2290                    row_text(row, "kind").as_deref() == Some("queue_attempts_lc")
2291                        && row_text(row, "queue").as_deref() == Some(queue)
2292                })
2293            })
2294            .into_iter()
2295            .filter_map(|entity| match entity.data {
2296                EntityData::Row(row) => Some((entity.id, row)),
2297                _ => None,
2298            })
2299            .collect()
2300    });
2301    let attempts_by_key: HashMap<(String, String, u64), u64> = attempts
2302        .into_iter()
2303        .filter_map(|(_, row)| {
2304            Some((
2305                (
2306                    row_text(&row, "queue")?,
2307                    row_text(&row, "group")?,
2308                    row_u64(&row, "message_id")?,
2309                ),
2310                row_u64(&row, "attempts").unwrap_or(1),
2311            ))
2312        })
2313        .collect();
2314    let mut pending = Vec::new();
2315    for kind in ["queue_pending", "queue_pending_lc"] {
2316        match super::queue_storage_index::subject_rows_store(
2317            store,
2318            kind,
2319            queue,
2320            group,
2321            message_id.map(|id| id.raw()),
2322        )? {
2323            Some(rows) => pending.extend(rows),
2324            None => pending.extend(
2325                manager
2326                    .query_all(|entity| {
2327                        entity.data.as_row().is_some_and(|row| {
2328                            row_text(row, "kind").as_deref() == Some(kind)
2329                                && row_text(row, "queue").as_deref() == Some(queue)
2330                                && group
2331                                    .map(|group_name| {
2332                                        row_text(row, "group").as_deref() == Some(group_name)
2333                                    })
2334                                    .unwrap_or(true)
2335                                && message_id
2336                                    .map(|candidate| {
2337                                        row_u64(row, "message_id") == Some(candidate.raw())
2338                                    })
2339                                    .unwrap_or(true)
2340                        })
2341                    })
2342                    .into_iter()
2343                    .filter_map(|entity| match entity.data {
2344                        EntityData::Row(row) => Some((entity.id, row)),
2345                        _ => None,
2346                    }),
2347            ),
2348        }
2349    }
2350    pending.sort_unstable_by_key(|(id, _)| id.raw());
2351    Ok(pending
2352        .into_iter()
2353        .filter_map(|(entity_id, row)| {
2354            let group = row_text(&row, "group")?;
2355            let message_id = row_u64(&row, "message_id")?;
2356            let kind = row_text(&row, "kind")?;
2357            let delivered_at_ns = if kind == "queue_pending_lc" {
2358                row_u64(&row, "lock_deadline_ns")
2359                    .unwrap_or(0)
2360                    .saturating_sub(lock_deadline_ns)
2361            } else {
2362                row_u64(&row, "delivered_at_ns")?
2363            };
2364            let delivery_count = if kind == "queue_pending_lc" {
2365                attempts_by_key
2366                    .get(&(queue.to_string(), group.clone(), message_id))
2367                    .copied()
2368                    .unwrap_or(1)
2369            } else {
2370                row_u64(&row, "delivery_count").unwrap_or(1)
2371            };
2372            Some(QueuePendingEntry {
2373                entity_id,
2374                group,
2375                message_id: EntityId::new(message_id),
2376                consumer: row_text(&row, "consumer").unwrap_or_default(),
2377                delivered_at_ns,
2378                delivery_count: delivery_count as u32,
2379            })
2380        })
2381        .collect())
2382}
2383
2384pub(super) fn save_queue_pending(
2385    store: &UnifiedStore,
2386    queue: &str,
2387    group: &str,
2388    message_id: EntityId,
2389    consumer: &str,
2390    delivered_at_ns: u64,
2391    delivery_count: u32,
2392) -> RedDBResult<()> {
2393    match super::queue_storage_index::subject_rows_store(
2394        store,
2395        "queue_pending",
2396        queue,
2397        Some(group),
2398        Some(message_id.raw()),
2399    )? {
2400        Some(rows) => {
2401            for (id, _) in rows {
2402                let _ = store.delete(QUEUE_META_COLLECTION, id);
2403            }
2404        }
2405        None => remove_meta_rows(store, |row| {
2406            row_text(row, "kind").as_deref() == Some("queue_pending")
2407                && row_text(row, "queue").as_deref() == Some(queue)
2408                && row_text(row, "group").as_deref() == Some(group)
2409                && row_u64(row, "message_id") == Some(message_id.raw())
2410        }),
2411    }
2412
2413    let mut fields = HashMap::new();
2414    fields.insert("kind".to_string(), Value::text("queue_pending".to_string()));
2415    fields.insert("queue".to_string(), Value::text(queue.to_string()));
2416    fields.insert("group".to_string(), Value::text(group.to_string()));
2417    fields.insert(
2418        "message_id".to_string(),
2419        Value::UnsignedInteger(message_id.raw()),
2420    );
2421    fields.insert("consumer".to_string(), Value::text(consumer.to_string()));
2422    fields.insert(
2423        "delivered_at_ns".to_string(),
2424        Value::UnsignedInteger(delivered_at_ns),
2425    );
2426    fields.insert(
2427        "delivery_count".to_string(),
2428        Value::UnsignedInteger(u64::from(delivery_count)),
2429    );
2430    insert_meta_row(store, fields).map(|_| ())
2431}
2432
2433pub(super) fn require_pending_entry(
2434    store: &UnifiedStore,
2435    queue: &str,
2436    group: &str,
2437    message_id: EntityId,
2438) -> RedDBResult<QueuePendingEntry> {
2439    load_pending_entries(store, queue, Some(group), Some(message_id))?
2440        .into_iter()
2441        .next()
2442        .ok_or_else(|| {
2443            RedDBError::NotFound(format!(
2444                "message '{}' is not pending in group '{}' on queue '{}'",
2445                message_id.raw(),
2446                group,
2447                queue
2448            ))
2449        })
2450}
2451
2452pub(super) fn load_ack_entries(
2453    store: &UnifiedStore,
2454    queue: &str,
2455    group: Option<&str>,
2456    message_id: Option<EntityId>,
2457) -> RedDBResult<Vec<QueueAckEntry>> {
2458    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2459        return Ok(Vec::new());
2460    };
2461    let rows = match super::queue_storage_index::subject_rows_store(
2462        store,
2463        "queue_ack",
2464        queue,
2465        group,
2466        message_id.map(|id| id.raw()),
2467    )? {
2468        Some(rows) => rows,
2469        None => manager
2470            .query_all(|entity| {
2471                entity.data.as_row().is_some_and(|row| {
2472                    row_text(row, "kind").as_deref() == Some("queue_ack")
2473                        && row_text(row, "queue").as_deref() == Some(queue)
2474                        && group
2475                            .map(|group_name| row_text(row, "group").as_deref() == Some(group_name))
2476                            .unwrap_or(true)
2477                        && message_id
2478                            .map(|candidate| row_u64(row, "message_id") == Some(candidate.raw()))
2479                            .unwrap_or(true)
2480                })
2481            })
2482            .into_iter()
2483            .filter_map(|entity| match entity.data {
2484                EntityData::Row(row) => Some((entity.id, row)),
2485                _ => None,
2486            })
2487            .collect(),
2488    };
2489    Ok(rows
2490        .into_iter()
2491        .filter_map(|(entity_id, row)| {
2492            Some(QueueAckEntry {
2493                entity_id,
2494                group: row_text(&row, "group")?,
2495                message_id: EntityId::new(row_u64(&row, "message_id")?),
2496            })
2497        })
2498        .collect())
2499}
2500
2501pub(super) fn save_queue_ack(
2502    store: &UnifiedStore,
2503    queue: &str,
2504    group: &str,
2505    message_id: EntityId,
2506) -> RedDBResult<()> {
2507    let existing = load_ack_entries(store, queue, Some(group), Some(message_id))?;
2508    if !existing.is_empty() {
2509        return Ok(());
2510    }
2511
2512    let mut fields = HashMap::new();
2513    fields.insert("kind".to_string(), Value::text("queue_ack".to_string()));
2514    fields.insert("queue".to_string(), Value::text(queue.to_string()));
2515    fields.insert("group".to_string(), Value::text(group.to_string()));
2516    fields.insert(
2517        "message_id".to_string(),
2518        Value::UnsignedInteger(message_id.raw()),
2519    );
2520    fields.insert("acked_at_ns".to_string(), Value::UnsignedInteger(now_ns()));
2521    insert_meta_row(store, fields).map(|_| ())
2522}
2523
2524pub(super) fn queue_message_completed_for_all_groups(
2525    store: &UnifiedStore,
2526    queue: &str,
2527    message_id: EntityId,
2528) -> RedDBResult<bool> {
2529    let groups = load_queue_groups(store, queue)?;
2530    let pending = load_pending_entries(store, queue, None, Some(message_id))?;
2531    if !pending.is_empty() {
2532        return Ok(false);
2533    }
2534    if groups.is_empty() {
2535        return Ok(true);
2536    }
2537
2538    let acked_groups = load_ack_entries(store, queue, None, Some(message_id))?
2539        .into_iter()
2540        .map(|entry| entry.group)
2541        .collect::<HashSet<_>>();
2542    Ok(groups
2543        .into_iter()
2544        .all(|group| acked_groups.contains(&group.group)))
2545}
2546
2547fn load_queue_message_views(
2548    store: &UnifiedStore,
2549    queue: &str,
2550) -> RedDBResult<Vec<QueueMessageView>> {
2551    load_queue_message_views_with_runtime(None, store, queue)
2552}
2553
2554/// Kind-aware queue scan (Phase 2.5.5 RLS universal). When the
2555/// caller has a `RedDBRuntime` reference, the gate also applies
2556/// any `CREATE POLICY ... ON MESSAGES OF <queue>` predicate. In
2557/// autocommit / embedded paths that only have the raw store (e.g.
2558/// purge loops) we skip RLS because there's no session identity
2559/// to match against.
2560pub(super) fn load_queue_message_views_with_runtime(
2561    runtime: Option<&RedDBRuntime>,
2562    store: &UnifiedStore,
2563    queue: &str,
2564) -> RedDBResult<Vec<QueueMessageView>> {
2565    let manager = store
2566        .get_collection(queue)
2567        .ok_or_else(|| RedDBError::NotFound(format!("queue '{}' not found", queue)))?;
2568    // Phase 1.2 MVCC universal: capture before parallel scan. Messages
2569    // inserted by another connection's open txn stay invisible to
2570    // consumers until that txn commits (prevents phantom POPs).
2571    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
2572    let rls_filter = runtime.and_then(|rt| {
2573        crate::runtime::impl_core::rls_policy_filter_for_kind(
2574            rt,
2575            queue,
2576            reddb_rql::ast::PolicyAction::Select,
2577            reddb_rql::ast::PolicyTargetKind::Messages,
2578        )
2579    });
2580    let rls_enabled_but_denied = runtime.map(|rt| rt.is_rls_enabled(queue)).unwrap_or(false)
2581        && rls_filter.is_none()
2582        && runtime.is_some();
2583    if rls_enabled_but_denied {
2584        // RLS on + no Messages policy for this role = deny-default.
2585        return Ok(Vec::new());
2586    }
2587    let filter_arc = rls_filter.map(std::sync::Arc::new);
2588    let rt_arc = runtime;
2589    Ok(manager
2590        .scan(snap_ctx.as_ref(), move |entity| {
2591            if !matches!(entity.kind, EntityKind::QueueMessage { .. }) {
2592                return false;
2593            }
2594            if let (Some(filter), Some(rt)) = (filter_arc.as_ref(), rt_arc) {
2595                return crate::runtime::query_exec::evaluate_entity_filter_with_db(
2596                    Some(&rt.inner.db),
2597                    entity,
2598                    filter,
2599                    queue,
2600                    queue,
2601                );
2602            }
2603            true
2604        })
2605        .into_iter()
2606        .filter_map(queue_message_view_from_entity)
2607        .map(|mut view| {
2608            view.available_at_ns = read_message_available_at_ns(store, queue, view.id);
2609            view.ordering_key = read_message_ordering_key(store, queue, view.id);
2610            view
2611        })
2612        .collect())
2613}
2614
2615fn queue_message_view_from_entity(entity: UnifiedEntity) -> Option<QueueMessageView> {
2616    let (position, _) = match &entity.kind {
2617        EntityKind::QueueMessage { position, queue } => (*position, queue),
2618        _ => return None,
2619    };
2620    let data = match entity.data {
2621        EntityData::QueueMessage(data) => data,
2622        _ => return None,
2623    };
2624    Some(QueueMessageView {
2625        id: entity.id,
2626        position,
2627        priority: data.priority.unwrap_or(0),
2628        payload: data.payload,
2629        attempts: data.attempts,
2630        max_attempts: data.max_attempts,
2631        enqueued_at_ns: data.enqueued_at_ns,
2632        available_at_ns: None,
2633        ordering_key: None,
2634    })
2635}
2636
2637/// Insert a moved payload onto `queue` using only the payload value —
2638/// priority / attempts / TTL fall back to the destination queue's
2639/// catalog config (mirrors a fresh enqueue rather than carrying source
2640/// metadata over). Used by `PrimaryQueueStore::move_to_queue`, the
2641/// `QueueLifecycle::move_between_queues` adapter that owns only
2642/// `(message_id, payload)` after `pop_messages` retires the source row.
2643pub(super) fn insert_moved_queue_message_payload(
2644    store: &UnifiedStore,
2645    queue: &str,
2646    payload: &Value,
2647) -> RedDBResult<EntityId> {
2648    let config = load_queue_config(store, queue);
2649    let position = next_queue_position(store, queue, QueueSide::Right)?;
2650    let enqueued_at_ns = std::time::SystemTime::now()
2651        .duration_since(std::time::UNIX_EPOCH)
2652        .map(|d| d.as_nanos() as u64)
2653        .unwrap_or(0);
2654    let entity = UnifiedEntity::new(
2655        EntityId::new(0),
2656        EntityKind::QueueMessage {
2657            queue: queue.to_string(),
2658            position,
2659        },
2660        EntityData::QueueMessage(QueueMessageData {
2661            payload: payload.clone(),
2662            priority: None,
2663            enqueued_at_ns,
2664            attempts: 0,
2665            max_attempts: config.max_attempts,
2666            acked: false,
2667        }),
2668    );
2669    let id = store
2670        .insert_auto(queue, entity)
2671        .map_err(|err| RedDBError::Internal(err.to_string()))?;
2672    if let Some(ttl_ms) = config.ttl_ms {
2673        store
2674            .set_metadata(queue, id, queue_message_ttl_metadata(ttl_ms))
2675            .map_err(|err| RedDBError::Internal(err.to_string()))?;
2676    }
2677    Ok(id)
2678}
2679
2680fn insert_moved_queue_message(
2681    store: &UnifiedStore,
2682    queue: &str,
2683    config: &QueueRuntimeConfig,
2684    message: &QueueMessageView,
2685) -> RedDBResult<EntityId> {
2686    let position = next_queue_position(store, queue, QueueSide::Right)?;
2687    let entity = UnifiedEntity::new(
2688        EntityId::new(0),
2689        EntityKind::QueueMessage {
2690            queue: queue.to_string(),
2691            position,
2692        },
2693        EntityData::QueueMessage(QueueMessageData {
2694            payload: message.payload.clone(),
2695            priority: if config.priority {
2696                Some(message.priority)
2697            } else {
2698                None
2699            },
2700            enqueued_at_ns: message.enqueued_at_ns,
2701            attempts: 0,
2702            max_attempts: config.max_attempts,
2703            acked: false,
2704        }),
2705    );
2706    let id = store
2707        .insert_auto(queue, entity)
2708        .map_err(|err| RedDBError::Internal(err.to_string()))?;
2709    if config.ttl_ms.is_some() || message.ordering_key.is_some() {
2710        store
2711            .set_metadata(
2712                queue,
2713                id,
2714                queue_message_metadata(config.ttl_ms, None, message.ordering_key.as_deref()),
2715            )
2716            .map_err(|err| RedDBError::Internal(err.to_string()))?;
2717    }
2718    Ok(id)
2719}
2720
2721fn queue_projection_default_columns() -> Vec<String> {
2722    [
2723        "id",
2724        "payload",
2725        "priority",
2726        "attempts",
2727        "last_error",
2728        "enqueued_at",
2729        "available_at",
2730        "key",
2731        "dlq",
2732        "tenant",
2733    ]
2734    .into_iter()
2735    .map(str::to_string)
2736    .collect()
2737}
2738
2739fn queue_projection_record(
2740    columns: &[String],
2741    message: &QueueMessageView,
2742    dlq: bool,
2743) -> RedDBResult<UnifiedRecord> {
2744    let mut record = UnifiedRecord::new();
2745    for column in columns {
2746        let value = queue_projection_value(message, dlq, column).ok_or_else(|| {
2747            RedDBError::Query(format!("unknown queue projection column '{}'", column))
2748        })?;
2749        record.set(column, value);
2750    }
2751    Ok(record)
2752}
2753
2754fn queue_projection_value(message: &QueueMessageView, dlq: bool, column: &str) -> Option<Value> {
2755    match column {
2756        "id" => Some(Value::text(message_id_string(message.id))),
2757        "payload" => Some(message.payload.clone()),
2758        "priority" => Some(Value::Integer(i64::from(message.priority))),
2759        "attempts" => Some(Value::UnsignedInteger(u64::from(message.attempts))),
2760        "last_error" => Some(Value::Null),
2761        "enqueued_at" => Some(Value::UnsignedInteger(message.enqueued_at_ns)),
2762        "available_at" => Some(Value::UnsignedInteger(
2763            message.available_at_ns.unwrap_or(message.enqueued_at_ns),
2764        )),
2765        "key" => Some(
2766            message
2767                .ordering_key
2768                .as_ref()
2769                .map(|key| Value::text(key.clone()))
2770                .unwrap_or(Value::Null),
2771        ),
2772        "dlq" => Some(Value::Boolean(dlq)),
2773        "tenant" => queue_message_tenant(&message.payload).or(Some(Value::Null)),
2774        _ => None,
2775    }
2776}
2777
2778fn queue_message_tenant(payload: &Value) -> Option<Value> {
2779    let Value::Json(bytes) = payload else {
2780        return None;
2781    };
2782    let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
2783    json.get("tenant")
2784        .and_then(crate::json::Value::as_str)
2785        .map(|tenant| Value::text(tenant.to_string()))
2786}
2787
2788fn queue_is_dead_letter_target(store: &UnifiedStore, queue: &str) -> bool {
2789    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2790        return false;
2791    };
2792    !manager
2793        .query_all(|entity| {
2794            entity.data.as_row().is_some_and(|row| {
2795                row_text(row, "kind").as_deref() == Some("queue_config")
2796                    && row_text(row, "dlq").as_deref() == Some(queue)
2797            })
2798        })
2799        .is_empty()
2800}
2801
2802fn queue_message_matches_filter(message: &QueueMessageView, dlq: bool, filter: &Filter) -> bool {
2803    match filter {
2804        Filter::Compare { field, op, value } => queue_filter_field_value(message, dlq, field)
2805            .is_some_and(|candidate| queue_compare_values(&candidate, value, *op)),
2806        Filter::CompareFields { left, op, right } => {
2807            match (
2808                queue_filter_field_value(message, dlq, left),
2809                queue_filter_field_value(message, dlq, right),
2810            ) {
2811                (Some(left), Some(right)) => queue_compare_values(&left, &right, *op),
2812                _ => false,
2813            }
2814        }
2815        Filter::And(left, right) => {
2816            queue_message_matches_filter(message, dlq, left)
2817                && queue_message_matches_filter(message, dlq, right)
2818        }
2819        Filter::Or(left, right) => {
2820            queue_message_matches_filter(message, dlq, left)
2821                || queue_message_matches_filter(message, dlq, right)
2822        }
2823        Filter::Not(inner) => !queue_message_matches_filter(message, dlq, inner),
2824        Filter::IsNull(field) => queue_filter_field_value(message, dlq, field)
2825            .is_none_or(|value| matches!(value, Value::Null)),
2826        Filter::IsNotNull(field) => queue_filter_field_value(message, dlq, field)
2827            .is_some_and(|value| !matches!(value, Value::Null)),
2828        Filter::In { field, values } => {
2829            queue_filter_field_value(message, dlq, field).is_some_and(|candidate| {
2830                values
2831                    .iter()
2832                    .any(|value| queue_values_equal(&candidate, value))
2833            })
2834        }
2835        Filter::Between { field, low, high } => queue_filter_field_value(message, dlq, field)
2836            .is_some_and(|candidate| {
2837                queue_compare_values(&candidate, low, CompareOp::Ge)
2838                    && queue_compare_values(&candidate, high, CompareOp::Le)
2839            }),
2840        Filter::Like { field, pattern } => queue_filter_text(message, dlq, field)
2841            .is_some_and(|value| queue_like_matches(&value, pattern)),
2842        Filter::StartsWith { field, prefix } => {
2843            queue_filter_text(message, dlq, field).is_some_and(|value| value.starts_with(prefix))
2844        }
2845        Filter::EndsWith { field, suffix } => {
2846            queue_filter_text(message, dlq, field).is_some_and(|value| value.ends_with(suffix))
2847        }
2848        Filter::Contains { field, substring } => {
2849            queue_filter_text(message, dlq, field).is_some_and(|value| value.contains(substring))
2850        }
2851        Filter::CompareExpr { .. } => false,
2852    }
2853}
2854
2855fn queue_filter_field_value(
2856    message: &QueueMessageView,
2857    dlq: bool,
2858    field: &FieldRef,
2859) -> Option<Value> {
2860    match field {
2861        FieldRef::TableColumn { table, column } if table.is_empty() => {
2862            queue_projection_value(message, dlq, column)
2863                .or_else(|| queue_payload_field_value(&message.payload, column))
2864        }
2865        FieldRef::TableColumn { column, .. } => queue_projection_value(message, dlq, column)
2866            .or_else(|| queue_payload_field_value(&message.payload, column)),
2867        _ => None,
2868    }
2869}
2870
2871fn queue_payload_field_value(payload: &Value, field: &str) -> Option<Value> {
2872    let Value::Json(bytes) = payload else {
2873        return None;
2874    };
2875    let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
2876    let value = json.get(field)?;
2877    json_value_to_schema_value(value)
2878}
2879
2880fn json_value_to_schema_value(value: &crate::json::Value) -> Option<Value> {
2881    if matches!(value, crate::json::Value::Null) {
2882        Some(Value::Null)
2883    } else if let Some(value) = value.as_bool() {
2884        Some(Value::Boolean(value))
2885    } else if let Some(value) = value.as_i64() {
2886        Some(Value::Integer(value))
2887    } else if let Some(value) = value.as_u64() {
2888        Some(Value::UnsignedInteger(value))
2889    } else if let Some(value) = value.as_f64() {
2890        Some(Value::Float(value))
2891    } else if let Some(value) = value.as_str() {
2892        Some(Value::text(value.to_string()))
2893    } else {
2894        Some(Value::Json(value.to_string_compact().into_bytes()))
2895    }
2896}
2897
2898fn queue_filter_text(message: &QueueMessageView, dlq: bool, field: &FieldRef) -> Option<String> {
2899    queue_filter_field_value(message, dlq, field).and_then(|value| match value {
2900        Value::Text(value) => Some(value.to_string()),
2901        Value::NodeRef(value) | Value::EdgeRef(value) | Value::TableRef(value) => Some(value),
2902        Value::Integer(value) => Some(value.to_string()),
2903        Value::UnsignedInteger(value) => Some(value.to_string()),
2904        Value::Float(value) => Some(value.to_string()),
2905        Value::Boolean(value) => Some(value.to_string()),
2906        _ => None,
2907    })
2908}
2909
2910fn queue_compare_values(left: &Value, right: &Value, op: CompareOp) -> bool {
2911    match op {
2912        CompareOp::Eq => queue_values_equal(left, right),
2913        CompareOp::Ne => !queue_values_equal(left, right),
2914        CompareOp::Lt => queue_partial_cmp(left, right).is_some_and(|ord| ord.is_lt()),
2915        CompareOp::Le => queue_partial_cmp(left, right).is_some_and(|ord| !ord.is_gt()),
2916        CompareOp::Gt => queue_partial_cmp(left, right).is_some_and(|ord| ord.is_gt()),
2917        CompareOp::Ge => queue_partial_cmp(left, right).is_some_and(|ord| !ord.is_lt()),
2918    }
2919}
2920
2921fn queue_values_equal(left: &Value, right: &Value) -> bool {
2922    if let (Some(left), Some(right)) = (queue_value_number(left), queue_value_number(right)) {
2923        return (left - right).abs() < f64::EPSILON;
2924    }
2925    match (left, right) {
2926        (Value::Text(left), Value::Text(right)) => left == right,
2927        (Value::Boolean(left), Value::Boolean(right)) => left == right,
2928        _ => left == right,
2929    }
2930}
2931
2932fn queue_partial_cmp(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
2933    if let (Some(left), Some(right)) = (queue_value_number(left), queue_value_number(right)) {
2934        return left.partial_cmp(&right);
2935    }
2936    match (left, right) {
2937        (Value::Text(left), Value::Text(right)) => Some(left.cmp(right)),
2938        _ => None,
2939    }
2940}
2941
2942fn queue_value_number(value: &Value) -> Option<f64> {
2943    match value {
2944        Value::Integer(value) => Some(*value as f64),
2945        Value::UnsignedInteger(value) => Some(*value as f64),
2946        Value::Float(value) => Some(*value),
2947        Value::Text(value) => value.parse().ok(),
2948        _ => None,
2949    }
2950}
2951
2952fn queue_like_matches(value: &str, pattern: &str) -> bool {
2953    if pattern == "%" {
2954        return true;
2955    }
2956    let starts_wild = pattern.starts_with('%');
2957    let ends_wild = pattern.ends_with('%');
2958    let needle = pattern.trim_matches('%');
2959    match (starts_wild, ends_wild) {
2960        (true, true) => value.contains(needle),
2961        (true, false) => value.ends_with(needle),
2962        (false, true) => value.starts_with(needle),
2963        (false, false) => value == needle,
2964    }
2965}
2966
2967pub(super) fn queue_message_view_by_id(
2968    store: &UnifiedStore,
2969    queue: &str,
2970    message_id: EntityId,
2971) -> RedDBResult<Option<QueueMessageView>> {
2972    let manager = queue_manager(store, queue)?;
2973    Ok(manager
2974        .get(message_id)
2975        .and_then(queue_message_view_from_entity)
2976        .map(|mut view| {
2977            view.available_at_ns = read_message_available_at_ns(store, queue, view.id);
2978            view
2979        }))
2980}
2981
2982pub(super) fn sort_queue_messages(
2983    messages: &mut [QueueMessageView],
2984    config: &QueueRuntimeConfig,
2985    side: QueueSide,
2986) {
2987    messages.sort_by(|left, right| {
2988        if config.priority {
2989            right
2990                .priority
2991                .cmp(&left.priority)
2992                .then_with(|| match side {
2993                    QueueSide::Left => left.position.cmp(&right.position),
2994                    QueueSide::Right => right.position.cmp(&left.position),
2995                })
2996                .then_with(|| left.id.raw().cmp(&right.id.raw()))
2997        } else {
2998            match side {
2999                QueueSide::Left => left.position.cmp(&right.position),
3000                QueueSide::Right => right.position.cmp(&left.position),
3001            }
3002            .then_with(|| left.id.raw().cmp(&right.id.raw()))
3003        }
3004    });
3005}
3006
3007pub(super) fn next_queue_position(
3008    store: &UnifiedStore,
3009    queue: &str,
3010    side: QueueSide,
3011) -> RedDBResult<u64> {
3012    if let Some(boundary) = super::queue_storage_index::position_boundary(
3013        store,
3014        queue,
3015        matches!(side, QueueSide::Right),
3016    )? {
3017        return Ok(
3018            boundary.map_or(QUEUE_POSITION_CENTER, |position| match side {
3019                QueueSide::Left => position.saturating_sub(1),
3020                QueueSide::Right => position.saturating_add(1),
3021            }),
3022        );
3023    }
3024    let manager = store
3025        .get_collection(queue)
3026        .ok_or_else(|| RedDBError::NotFound(format!("queue '{queue}' not found")))?;
3027    let snapshot = crate::runtime::impl_core::capture_current_snapshot();
3028    let mut boundary: Option<u64> = None;
3029    // Positioning needs one scalar, not copies of every queued payload. The
3030    // fallible merged visitor also refuses a partial/empty answer on cold I/O.
3031    manager.try_scan_for_each(snapshot.as_ref(), usize::MAX, |entity| {
3032        let EntityKind::QueueMessage { position, .. } = &entity.kind else {
3033            return true;
3034        };
3035        if !matches!(entity.data, EntityData::QueueMessage(_)) {
3036            return true;
3037        }
3038        boundary = Some(boundary.map_or(*position, |current| match side {
3039            QueueSide::Left => current.min(*position),
3040            QueueSide::Right => current.max(*position),
3041        }));
3042        true
3043    })?;
3044    Ok(
3045        boundary.map_or(QUEUE_POSITION_CENTER, |position| match side {
3046            QueueSide::Left => position.saturating_sub(1),
3047            QueueSide::Right => position.saturating_add(1),
3048        }),
3049    )
3050}
3051
3052pub(super) fn increment_queue_attempts(
3053    store: &UnifiedStore,
3054    queue: &str,
3055    message_id: EntityId,
3056) -> RedDBResult<u32> {
3057    let manager = queue_manager(store, queue)?;
3058    let mut entity = manager
3059        .get(message_id)
3060        .ok_or_else(|| RedDBError::NotFound(format!("message '{}' not found", message_id.raw())))?;
3061    match &mut entity.data {
3062        EntityData::QueueMessage(message) => {
3063            message.attempts = message.attempts.saturating_add(1);
3064            let attempts = message.attempts;
3065            manager
3066                .update(entity)
3067                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3068            Ok(attempts)
3069        }
3070        _ => Err(RedDBError::Query(format!(
3071            "entity '{}' is not a queue message",
3072            message_id.raw()
3073        ))),
3074    }
3075}
3076
3077pub(super) fn queue_message_attempts(
3078    store: &UnifiedStore,
3079    queue: &str,
3080    message_id: EntityId,
3081) -> RedDBResult<u32> {
3082    Ok(queue_message_data(store, queue, message_id)?.attempts)
3083}
3084
3085pub(super) fn queue_message_max_attempts(
3086    store: &UnifiedStore,
3087    queue: &str,
3088    message_id: EntityId,
3089) -> RedDBResult<u32> {
3090    Ok(queue_message_data(store, queue, message_id)?.max_attempts)
3091}
3092
3093pub(super) fn queue_message_payload(
3094    store: &UnifiedStore,
3095    queue: &str,
3096    message_id: EntityId,
3097) -> RedDBResult<Value> {
3098    Ok(queue_message_data(store, queue, message_id)?.payload)
3099}
3100
3101pub(super) fn queue_message_pending_any(
3102    store: &UnifiedStore,
3103    queue: &str,
3104    message_id: EntityId,
3105) -> RedDBResult<bool> {
3106    Ok(!load_pending_entries(store, queue, None, Some(message_id))?.is_empty())
3107}
3108
3109pub(super) fn queue_message_pending_for_group(
3110    store: &UnifiedStore,
3111    queue: &str,
3112    group: &str,
3113    message_id: EntityId,
3114) -> RedDBResult<bool> {
3115    Ok(!load_pending_entries(store, queue, Some(group), Some(message_id))?.is_empty())
3116}
3117
3118pub(super) fn queue_message_acked_for_group(
3119    store: &UnifiedStore,
3120    queue: &str,
3121    group: &str,
3122    message_id: EntityId,
3123) -> RedDBResult<bool> {
3124    Ok(!load_ack_entries(store, queue, Some(group), Some(message_id))?.is_empty())
3125}
3126
3127fn queue_manager(
3128    store: &UnifiedStore,
3129    queue: &str,
3130) -> RedDBResult<Arc<crate::storage::unified::SegmentManager>> {
3131    store
3132        .get_collection(queue)
3133        .ok_or_else(|| RedDBError::NotFound(format!("queue '{}' not found", queue)))
3134}
3135
3136pub(super) fn queue_message_data(
3137    store: &UnifiedStore,
3138    queue: &str,
3139    message_id: EntityId,
3140) -> RedDBResult<QueueMessageData> {
3141    let manager = queue_manager(store, queue)?;
3142    let entity = manager
3143        .get(message_id)
3144        .ok_or_else(|| RedDBError::NotFound(format!("message '{}' not found", message_id.raw())))?;
3145    match entity.data {
3146        EntityData::QueueMessage(message) => Ok(message),
3147        _ => Err(RedDBError::Query(format!(
3148            "entity '{}' is not a queue message",
3149            message_id.raw()
3150        ))),
3151    }
3152}
3153
3154fn insert_meta_row(store: &UnifiedStore, fields: HashMap<String, Value>) -> RedDBResult<EntityId> {
3155    let created = store.get_collection(QUEUE_META_COLLECTION).is_none();
3156    let _ = store.get_or_create_collection(QUEUE_META_COLLECTION);
3157    if created {
3158        super::queue_storage_index::prepare_meta_store(store)?;
3159    }
3160    let id = store
3161        .insert_auto(
3162            QUEUE_META_COLLECTION,
3163            UnifiedEntity::new(
3164                EntityId::new(0),
3165                EntityKind::TableRow {
3166                    table: Arc::from(QUEUE_META_COLLECTION),
3167                    row_id: 0,
3168                },
3169                EntityData::Row(RowData {
3170                    columns: Vec::new(),
3171                    named: Some(fields),
3172                    schema: None,
3173                }),
3174            ),
3175        )
3176        .map_err(|err| RedDBError::Internal(err.to_string()))?;
3177    Ok(id)
3178}
3179
3180pub(super) fn remove_meta_rows(store: &UnifiedStore, predicate: impl Fn(&RowData) -> bool + Sync) {
3181    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3182        return;
3183    };
3184    let rows = manager.query_all(|entity| entity.data.as_row().is_some_and(&predicate));
3185    for row in rows {
3186        let _ = store.delete(QUEUE_META_COLLECTION, row.id);
3187    }
3188}
3189
3190pub(super) fn reclaim_expired_queue_dedup(
3191    store: &UnifiedStore,
3192    queue: &str,
3193    now_ns: u64,
3194) -> RedDBResult<usize> {
3195    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3196        return Ok(0);
3197    };
3198    let queue = queue.to_string();
3199    let rows = manager.query_all(|entity| {
3200        entity.data.as_row().is_some_and(|row| {
3201            row_text(row, "kind").as_deref() == Some(KIND_QUEUE_PUSH_DEDUP)
3202                && row_text(row, "queue").as_deref() == Some(&queue)
3203                && row_u64(row, "expires_at_ns")
3204                    .map(|expires_at| expires_at <= now_ns)
3205                    .unwrap_or(true)
3206        })
3207    });
3208    let count = rows.len();
3209    for row in rows {
3210        store
3211            .delete(QUEUE_META_COLLECTION, row.id)
3212            .map_err(|err| RedDBError::Internal(err.to_string()))?;
3213    }
3214    Ok(count)
3215}
3216
3217pub(super) fn find_queue_dedup(
3218    store: &UnifiedStore,
3219    queue: &str,
3220    dedup_key: &str,
3221    now_ns: u64,
3222) -> Option<EntityId> {
3223    let manager = store.get_collection(QUEUE_META_COLLECTION)?;
3224    let queue = queue.to_string();
3225    let dedup_key = dedup_key.to_string();
3226    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
3227    manager
3228        .scan(snap_ctx.as_ref(), |entity| {
3229            entity.data.as_row().is_some_and(|row| {
3230                row_text(row, "kind").as_deref() == Some(KIND_QUEUE_PUSH_DEDUP)
3231                    && row_text(row, "queue").as_deref() == Some(&queue)
3232                    && row_text(row, "dedup_key").as_deref() == Some(&dedup_key)
3233                    && row_u64(row, "expires_at_ns")
3234                        .map(|expires_at| expires_at > now_ns)
3235                        .unwrap_or(false)
3236            })
3237        })
3238        .into_iter()
3239        .filter_map(|entity| {
3240            let row = entity.data.as_row()?;
3241            row_u64(row, "message_id").map(EntityId::new)
3242        })
3243        .next()
3244}
3245
3246pub(super) fn record_queue_dedup(
3247    store: &UnifiedStore,
3248    queue: &str,
3249    dedup_key: &str,
3250    message_id: EntityId,
3251    expires_at_ns: u64,
3252) -> RedDBResult<EntityId> {
3253    let mut fields = HashMap::new();
3254    fields.insert(
3255        "kind".to_string(),
3256        Value::text(KIND_QUEUE_PUSH_DEDUP.to_string()),
3257    );
3258    fields.insert("queue".to_string(), Value::text(queue.to_string()));
3259    fields.insert("dedup_key".to_string(), Value::text(dedup_key.to_string()));
3260    fields.insert(
3261        "message_id".to_string(),
3262        Value::UnsignedInteger(message_id.raw()),
3263    );
3264    fields.insert(
3265        "expires_at_ns".to_string(),
3266        Value::UnsignedInteger(expires_at_ns),
3267    );
3268    insert_meta_row(store, fields)
3269}
3270
3271pub(super) fn delete_meta_entity(store: &UnifiedStore, entity_id: EntityId) {
3272    let _ = store.delete(QUEUE_META_COLLECTION, entity_id);
3273}
3274
3275fn queue_message_lock_key(queue: &str, message_id: EntityId) -> String {
3276    format!("{queue}:{}", message_id.raw())
3277}
3278
3279pub(super) fn queue_message_lock_handle(
3280    runtime: &RedDBRuntime,
3281    queue: &str,
3282    message_id: EntityId,
3283) -> Arc<parking_lot::Mutex<()>> {
3284    let key = queue_message_lock_key(queue, message_id);
3285    if let Some(lock) = runtime.inner.queue_message_locks.read().get(&key).cloned() {
3286        return lock;
3287    }
3288
3289    let mut locks = runtime.inner.queue_message_locks.write();
3290    locks
3291        .entry(key)
3292        .or_insert_with(|| Arc::new(parking_lot::Mutex::new(())))
3293        .clone()
3294}
3295
3296pub(super) fn forget_queue_message_lock(runtime: &RedDBRuntime, queue: &str, message_id: EntityId) {
3297    runtime
3298        .inner
3299        .queue_message_locks
3300        .write()
3301        .remove(&queue_message_lock_key(queue, message_id));
3302}
3303
3304fn parse_message_id(value: &str) -> RedDBResult<EntityId> {
3305    let raw = value.strip_prefix('e').unwrap_or(value);
3306    raw.parse::<u64>()
3307        .map(EntityId::new)
3308        .map_err(|_| RedDBError::Query(format!("invalid message id '{}'", value)))
3309}
3310
3311/// ADR 0026: resolve the ACK/NACK handle. When `delivery_id` is supplied,
3312/// it wins unconditionally — strict failure if the handle does not resolve
3313/// to a live pending delivery on `queue`. When only the legacy tuple is
3314/// supplied, emit a rate-limited deprecation log line and use the tuple.
3315/// At least one handle must be present.
3316pub(super) fn resolve_ack_nack_handle(
3317    store: &UnifiedStore,
3318    queue: &str,
3319    group_hint: &str,
3320    message_id_hint: &str,
3321    delivery_id: Option<&str>,
3322) -> RedDBResult<(String, EntityId)> {
3323    if let Some(did) = delivery_id {
3324        return resolve_delivery_id(store, queue, did);
3325    }
3326    if group_hint.is_empty() || message_id_hint.is_empty() {
3327        return Err(RedDBError::Query(
3328            "ACK/NACK requires either GROUP <group> '<message_id>' or WITH delivery_id = '<id>'"
3329                .to_string(),
3330        ));
3331    }
3332    log_tuple_deprecation(queue);
3333    let entity = parse_message_id(message_id_hint)?;
3334    Ok((group_hint.to_string(), entity))
3335}
3336
3337fn resolve_delivery_id(
3338    store: &UnifiedStore,
3339    queue: &str,
3340    delivery_id: &str,
3341) -> RedDBResult<(String, EntityId)> {
3342    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3343        return Err(RedDBError::Query(format!(
3344            "delivery_id '{}' does not resolve to a live pending delivery",
3345            delivery_id
3346        )));
3347    };
3348    let rows = match super::queue_storage_index::delivery_rows_store(store, delivery_id)? {
3349        Some(rows) => rows,
3350        None => manager
3351            .query_all(|entity| {
3352                entity.data.as_row().is_some_and(|row| {
3353                    row_text(row, "kind").as_deref() == Some("queue_pending_lc")
3354                        && row_text(row, "delivery_id").as_deref() == Some(delivery_id)
3355                })
3356            })
3357            .into_iter()
3358            .filter_map(|entity| match entity.data {
3359                EntityData::Row(row) => Some((entity.id, row)),
3360                _ => None,
3361            })
3362            .collect(),
3363    };
3364    for (_, row) in rows {
3365        if row_text(&row, "kind").as_deref() == Some("queue_pending_lc") {
3366            let row_queue = row_text(&row, "queue").unwrap_or_default();
3367            let row_group = row_text(&row, "group").unwrap_or_default();
3368            let row_message = row_u64(&row, "message_id").unwrap_or(0);
3369            if row_queue != queue {
3370                return Err(RedDBError::Query(format!(
3371                    "delivery_id '{}' belongs to queue '{}', not '{}'",
3372                    delivery_id, row_queue, queue
3373                )));
3374            }
3375            return Ok((row_group, EntityId::new(row_message)));
3376        }
3377    }
3378    Err(RedDBError::Query(format!(
3379        "delivery_id '{}' does not resolve to a live pending delivery",
3380        delivery_id
3381    )))
3382}
3383
3384/// Per-(connection, queue) rate-limited "tuple ACK is deprecated" log line.
3385/// One emission per minute matches ADR 0026's operational guidance.
3386fn log_tuple_deprecation(queue: &str) {
3387    use std::sync::atomic::Ordering;
3388    use std::sync::{Mutex, OnceLock};
3389    use std::time::Instant;
3390
3391    static LAST_EMIT: OnceLock<Mutex<HashMap<(u64, String), Instant>>> = OnceLock::new();
3392    const COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60);
3393
3394    let map = LAST_EMIT.get_or_init(|| Mutex::new(HashMap::new()));
3395    let key = (super::impl_core::current_connection_id(), queue.to_string());
3396    let now = Instant::now();
3397    let mut guard = match map.lock() {
3398        Ok(g) => g,
3399        Err(_) => return,
3400    };
3401    let should_emit =
3402        !matches!(guard.get(&key), Some(prev) if now.duration_since(*prev) < COOLDOWN);
3403    if should_emit {
3404        guard.insert(key.clone(), now);
3405        drop(guard);
3406        TUPLE_DEPRECATION_EMITS.fetch_add(1, Ordering::Relaxed);
3407        tracing::warn!(
3408            target: "reddb::queue_lifecycle",
3409            queue = queue,
3410            connection_id = key.0,
3411            "ACK/NACK by (queue, group, message_id) tuple is deprecated; \
3412             switch to the server-issued delivery_id (ADR 0026). \
3413             The tuple path will be removed one minor release after introduction.",
3414        );
3415    }
3416}
3417
3418/// Total count of tuple-deprecation log emissions since process start.
3419/// Intentionally process-wide and `pub` so the transport-bridge
3420/// integration tests can observe that the legacy tuple path emitted a
3421/// deprecation while the `delivery_id` path stayed silent, without
3422/// having to plumb a `tracing::Subscriber` through every test.
3423pub static TUPLE_DEPRECATION_EMITS: std::sync::atomic::AtomicU64 =
3424    std::sync::atomic::AtomicU64::new(0);
3425
3426fn message_id_string(message_id: EntityId) -> String {
3427    message_id.raw().to_string()
3428}
3429
3430/// Issue #917 — render a delivered queue message as the JSON object the
3431/// RedWire `QueueEventPush` frame carries. Mirrors the column shape the
3432/// SQL `QUEUE READ` projection emits (`message_id` / `payload` /
3433/// `consumer` / `delivery_count`) so the wire push and the pull path
3434/// stay client-compatible.
3435fn delivered_message_json(
3436    message: crate::runtime::queue_lifecycle::DeliveredMessage,
3437) -> crate::serde_json::Value {
3438    use crate::serde_json::{Map, Value as JsonValue};
3439    let mut obj = Map::new();
3440    obj.insert(
3441        "message_id".to_string(),
3442        JsonValue::String(message_id_string(EntityId::new(message.message_id))),
3443    );
3444    obj.insert("payload".to_string(), message.payload.to_json());
3445    obj.insert("consumer".to_string(), JsonValue::String(message.consumer));
3446    obj.insert(
3447        "delivery_count".to_string(),
3448        JsonValue::Number(message.delivery_count as f64),
3449    );
3450    JsonValue::Object(obj)
3451}
3452
3453/// Slice 10 of issue #527 — render-time scan of pending entries
3454/// per (queue, group) for `queue_pending_gauge` exposition. Walks
3455/// `red_queue_meta` live so the gauge cannot drift from the source
3456/// of truth.
3457pub(crate) fn pending_counts_by_group(
3458    store: &UnifiedStore,
3459) -> std::collections::BTreeMap<(String, String), u64> {
3460    let mut counts: std::collections::BTreeMap<(String, String), u64> =
3461        std::collections::BTreeMap::new();
3462    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3463        return counts;
3464    };
3465    for entity in manager.query_all(|entity| {
3466        entity
3467            .data
3468            .as_row()
3469            .is_some_and(|row| row_text(row, "kind").as_deref() == Some("queue_pending"))
3470    }) {
3471        if let Some(row) = entity.data.as_row() {
3472            let queue = row_text(row, "queue");
3473            let group = row_text(row, "group");
3474            if let (Some(q), Some(g)) = (queue, group) {
3475                *counts.entry((q, g)).or_insert(0) += 1;
3476            }
3477        }
3478    }
3479    counts
3480}
3481
3482pub(super) fn row_text(row: &RowData, field: &str) -> Option<String> {
3483    match row.get_field(field)?.clone() {
3484        Value::Text(value) => Some(value.to_string()),
3485        Value::NodeRef(value) => Some(value),
3486        Value::EdgeRef(value) => Some(value),
3487        Value::TableRef(value) => Some(value),
3488        _ => None,
3489    }
3490}
3491
3492pub(super) fn row_u64(row: &RowData, field: &str) -> Option<u64> {
3493    match row.get_field(field)?.clone() {
3494        Value::UnsignedInteger(value) => Some(value),
3495        Value::Integer(value) if value >= 0 => Some(value as u64),
3496        Value::Float(value) if value >= 0.0 => Some(value as u64),
3497        Value::Text(value) => value.parse().ok(),
3498        _ => None,
3499    }
3500}
3501
3502fn row_bool(row: &RowData, field: &str) -> Option<bool> {
3503    match row.get_field(field)?.clone() {
3504        Value::Boolean(value) => Some(value),
3505        Value::Text(value) => match value.to_ascii_lowercase().as_str() {
3506            "true" => Some(true),
3507            "false" => Some(false),
3508            _ => None,
3509        },
3510        _ => None,
3511    }
3512}
3513
3514fn queue_collection_contract(
3515    name: &str,
3516    priority: bool,
3517    ttl_ms: Option<u64>,
3518) -> crate::physical::CollectionContract {
3519    let now = current_unix_ms();
3520    let mut context_index_fields = Vec::new();
3521    if priority {
3522        context_index_fields.push("priority".to_string());
3523    }
3524
3525    crate::physical::CollectionContract {
3526        name: name.to_string(),
3527        declared_model: crate::catalog::CollectionModel::Queue,
3528        schema_mode: crate::catalog::SchemaMode::Dynamic,
3529        origin: crate::physical::ContractOrigin::Explicit,
3530        version: 1,
3531        created_at_unix_ms: now,
3532        updated_at_unix_ms: now,
3533        default_ttl_ms: ttl_ms,
3534        vector_dimension: None,
3535        vector_metric: None,
3536        context_index_fields,
3537        declared_columns: Vec::new(),
3538        table_def: None,
3539        timestamps_enabled: false,
3540        context_index_enabled: false,
3541        metrics_raw_retention_ms: None,
3542        metrics_rollup_policies: Vec::new(),
3543        metrics_tenant_identity: None,
3544        metrics_namespace: None,
3545        // Queues manipulate messages via push/pop/ack — the row DML
3546        // paths never apply. Flag it as append_only so inadvertent
3547        // `UPDATE/DELETE FROM queue_name` statements fail loudly.
3548        append_only: true,
3549        subscriptions: Vec::new(),
3550        analytics_config: Vec::new(),
3551        session_key: None,
3552        session_gap_ms: None,
3553        retention_duration_ms: None,
3554        analytical_storage: None,
3555
3556        ai_policy: None,
3557    }
3558}
3559
3560fn current_unix_ms() -> u128 {
3561    std::time::SystemTime::now()
3562        .duration_since(std::time::UNIX_EPOCH)
3563        .unwrap_or_default()
3564        .as_millis()
3565}
3566
3567pub(super) fn now_ns() -> u64 {
3568    std::time::SystemTime::now()
3569        .duration_since(std::time::UNIX_EPOCH)
3570        .unwrap_or_default()
3571        .as_nanos() as u64
3572}
3573
3574pub(super) fn queue_message_ttl_metadata(ttl_ms: u64) -> Metadata {
3575    queue_message_metadata(Some(ttl_ms), None, None)
3576}
3577
3578/// Build the per-message metadata row attached to a queue message. Both
3579/// fields are optional — `_ttl_ms` carries the per-message TTL (existing
3580/// behaviour) and `_available_at_ns` carries the first-delivery instant
3581/// for delayed messages (issue #722). When both are present they share
3582/// the same row, since `UnifiedStore::set_metadata` replaces the entry.
3583pub(super) fn queue_message_metadata(
3584    ttl_ms: Option<u64>,
3585    available_at_ns: Option<u64>,
3586    ordering_key: Option<&str>,
3587) -> Metadata {
3588    let mut fields = HashMap::new();
3589    if let Some(ttl_ms) = ttl_ms {
3590        fields.insert(
3591            "_ttl_ms".to_string(),
3592            if ttl_ms <= i64::MAX as u64 {
3593                MetadataValue::Int(ttl_ms as i64)
3594            } else {
3595                MetadataValue::Timestamp(ttl_ms)
3596            },
3597        );
3598    }
3599    if let Some(at_ns) = available_at_ns {
3600        fields.insert(
3601            "_available_at_ns".to_string(),
3602            MetadataValue::Timestamp(at_ns),
3603        );
3604    }
3605    if let Some(key) = ordering_key {
3606        fields.insert(
3607            "_ordering_key".to_string(),
3608            MetadataValue::String(key.to_string()),
3609        );
3610    }
3611    Metadata::with_fields(fields)
3612}
3613
3614/// Smallest future `available_at_ns` among messages currently sitting
3615/// on `queue`. Used by `QUEUE READ … WAIT` (issue #722) to cap the
3616/// condvar park horizon: without this, a waiter on a quiet queue with
3617/// only delayed messages would never wake when one became due, since
3618/// the wait registry is only notified by producer commits. Returns
3619/// `None` when no future-dated message exists (the common case — the
3620/// caller falls back to the user-supplied wait budget).
3621pub(super) fn earliest_future_available_at(store: &UnifiedStore, queue: &str) -> Option<u64> {
3622    let now_ns = now_ns();
3623    let views = load_queue_message_views_with_runtime(None, store, queue).ok()?;
3624    views
3625        .iter()
3626        .filter_map(|v| v.available_at_ns)
3627        .filter(|at| *at > now_ns)
3628        .min()
3629}
3630
3631/// Update the `_available_at_ns` metadata for a queue message in place
3632/// without dropping any `_ttl_ms` already present (issue #723 — used by
3633/// NACK retry delay). `available_at_ns = None` clears the field. The
3634/// `fallback_ttl_ms` argument is consulted only when the existing
3635/// metadata row carries no `_ttl_ms` — keeps the per-message TTL in
3636/// sync with the queue default in the common case where no per-message
3637/// TTL was set explicitly. Tolerant of missing collections / entities:
3638/// returns `Ok(())` rather than failing the surrounding NACK if the
3639/// underlying message has gone away between resolution and metadata
3640/// update (a benign race; the next delivery cycle reflects truth).
3641pub(super) fn set_message_available_at_ns(
3642    store: &UnifiedStore,
3643    queue: &str,
3644    message_id: EntityId,
3645    available_at_ns: Option<u64>,
3646    fallback_ttl_ms: Option<u64>,
3647) -> RedDBResult<()> {
3648    let existing_ttl_ms = store
3649        .get_metadata(queue, message_id)
3650        .and_then(|md| match md.get("_ttl_ms")? {
3651            MetadataValue::Int(i) if *i >= 0 => Some(*i as u64),
3652            MetadataValue::Timestamp(t) => Some(*t),
3653            _ => None,
3654        })
3655        .or(fallback_ttl_ms);
3656    let existing_ordering_key = read_message_ordering_key(store, queue, message_id);
3657    let metadata = queue_message_metadata(
3658        existing_ttl_ms,
3659        available_at_ns,
3660        existing_ordering_key.as_deref(),
3661    );
3662    match store.set_metadata(queue, message_id, metadata) {
3663        Ok(()) => Ok(()),
3664        Err(crate::storage::StoreError::CollectionNotFound(_)) => Ok(()),
3665        Err(err) => Err(RedDBError::Internal(err.to_string())),
3666    }
3667}
3668
3669/// Read the `_ordering_key` metadata for a queue message. Returns `None`
3670/// for keyless messages or when the metadata row is absent.
3671pub(super) fn read_message_ordering_key(
3672    store: &UnifiedStore,
3673    queue: &str,
3674    message_id: EntityId,
3675) -> Option<String> {
3676    let md = store.get_metadata(queue, message_id)?;
3677    match md.get("_ordering_key")? {
3678        MetadataValue::String(value) => Some(value.clone()),
3679        _ => None,
3680    }
3681}
3682
3683/// Read the `_available_at_ns` metadata for a queue message. Returns
3684/// `None` for messages with no delay (the common case) or when the
3685/// metadata row is missing entirely.
3686pub(super) fn read_message_available_at_ns(
3687    store: &UnifiedStore,
3688    queue: &str,
3689    message_id: EntityId,
3690) -> Option<u64> {
3691    let md = store.get_metadata(queue, message_id)?;
3692    match md.get("_available_at_ns")? {
3693        MetadataValue::Timestamp(t) => Some(*t),
3694        MetadataValue::Int(i) if *i >= 0 => Some(*i as u64),
3695        _ => None,
3696    }
3697}
3698
3699/// Rough payload byte estimate for outbox watermark tracking.
3700fn estimate_payload_bytes(payload: &Value) -> u64 {
3701    match payload {
3702        Value::Json(v) => v.len() as u64,
3703        Value::Text(s) => s.len() as u64,
3704        _ => 64,
3705    }
3706}
3707
3708#[cfg(test)]
3709mod presence_integration_tests {
3710    use super::*;
3711    use crate::storage::queue::presence::{PresenceState, DEFAULT_PRESENCE_TTL_MS};
3712    use crate::{RedDBOptions, RedDBRuntime};
3713
3714    /// Issue #742 acceptance: `QUEUE READ` must register and refresh
3715    /// consumer presence. The snapshot exposes the same `(queue, group,
3716    /// consumer)` triple the read named, with `state = Active`.
3717    #[test]
3718    fn queue_read_emits_consumer_presence_heartbeat() {
3719        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3720        rt.execute_query("CREATE QUEUE tasks").unwrap();
3721        rt.execute_query("QUEUE GROUP CREATE tasks workers")
3722            .unwrap();
3723        rt.execute_query("QUEUE PUSH tasks {'job':'a'}").unwrap();
3724        rt.execute_query("QUEUE READ tasks GROUP workers CONSUMER w1")
3725            .unwrap();
3726
3727        let snap = rt.queue_consumer_presence_snapshot(DEFAULT_PRESENCE_TTL_MS);
3728        assert_eq!(snap.len(), 1, "exactly one heartbeat recorded");
3729        let row = &snap[0];
3730        assert_eq!(row.queue, "tasks");
3731        assert_eq!(row.group, "workers");
3732        assert_eq!(row.consumer, "w1");
3733        assert_eq!(row.state, PresenceState::Active);
3734
3735        let counts = rt.queue_active_consumer_counts(DEFAULT_PRESENCE_TTL_MS);
3736        assert_eq!(
3737            counts[&("tasks".to_string(), "workers".to_string())],
3738            1,
3739            "active count reflects the live consumer"
3740        );
3741    }
3742
3743    /// Issue #742 acceptance: a read that returns no messages must
3744    /// still heartbeat — aliveness is independent of pending
3745    /// deliveries.
3746    #[test]
3747    fn empty_queue_read_still_heartbeats() {
3748        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3749        rt.execute_query("CREATE QUEUE empty_q").unwrap();
3750        rt.execute_query("QUEUE GROUP CREATE empty_q workers")
3751            .unwrap();
3752        // No PUSH — the queue is empty.
3753        rt.execute_query("QUEUE READ empty_q GROUP workers CONSUMER w1")
3754            .unwrap();
3755
3756        let snap = rt.queue_consumer_presence_snapshot(DEFAULT_PRESENCE_TTL_MS);
3757        assert_eq!(
3758            snap.len(),
3759            1,
3760            "empty read still registers consumer presence"
3761        );
3762        assert_eq!(snap[0].state, PresenceState::Active);
3763        assert_eq!(
3764            snap[0].lease_count, 0,
3765            "no messages delivered → zero leases"
3766        );
3767    }
3768}