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 full = match super::queue_storage_index::capacity_reached(
1028                        self, queue, max_size,
1029                    )? {
1030                        Some(full) => full,
1031                        None => {
1032                            queue_capacity_reached_by_scan(self, store.as_ref(), queue, max_size)?
1033                        }
1034                    };
1035                    if full {
1036                        return Err(RedDBError::Query(format!(
1037                            "queue '{}' is full (max_size={max_size})",
1038                            queue
1039                        )));
1040                    }
1041                }
1042
1043                let position = next_queue_position(store.as_ref(), queue, *side)?;
1044                let mut entity = UnifiedEntity::new(
1045                    EntityId::new(0),
1046                    EntityKind::QueueMessage {
1047                        queue: queue.clone(),
1048                        position,
1049                    },
1050                    EntityData::QueueMessage(QueueMessageData {
1051                        payload: value.clone(),
1052                        priority: if config.priority { *priority } else { None },
1053                        enqueued_at_ns: now_ns(),
1054                        attempts: 0,
1055                        max_attempts: config.max_attempts,
1056                        acked: false,
1057                    }),
1058                );
1059                // Phase 1.1 MVCC universal: stamp xmin so other
1060                // connections don't see this message until COMMIT.
1061                if let Some(xid) = self.current_xid() {
1062                    entity.set_xmin(xid);
1063                }
1064                // As for event inserts, reserve resident/WAL payload overlap
1065                // before publishing either the message or its metadata.
1066                let bytes = crate::storage::unified::memory_size::entity_bytes(&entity) as u64;
1067                let _admission = self.admit_non_evictable_growth(
1068                    crate::storage::memory_pools::MemoryPool::SegmentArena,
1069                    "queue push",
1070                    bytes.saturating_mul(3),
1071                )?;
1072                let id = if self.current_xid().is_none()
1073                    && !UnifiedStore::deferred_store_wal_capture_active()
1074                    && dedup_key.is_none()
1075                    && available.is_none()
1076                    && key.is_none()
1077                    && config.ttl_ms.is_none()
1078                    && config.max_size.is_none()
1079                {
1080                    entity.id = store.next_entity_id();
1081                    let id = entity.id;
1082                    store
1083                        .publish_rows(crate::storage::unified::store::RowPublication {
1084                            inserts: vec![(queue.clone(), entity, None)],
1085                            deletes: Vec::new(),
1086                        })
1087                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
1088                    id
1089                } else {
1090                    store
1091                        .insert_auto(queue, entity)
1092                        .map_err(|err| RedDBError::Internal(err.to_string()))?
1093                };
1094                if let Some(dedup_key) = dedup_key.as_deref() {
1095                    let window_ms = config
1096                        .dedup_window_ms
1097                        .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_DEDUP_WINDOW_MS);
1098                    let expires_at_ns =
1099                        now_ns().saturating_add(window_ms.saturating_mul(1_000_000));
1100                    let metadata_id =
1101                        record_queue_dedup(store.as_ref(), queue, dedup_key, id, expires_at_ns)?;
1102                    self.stamp_xmin_if_in_txn(QUEUE_META_COLLECTION, metadata_id);
1103                    if self.current_xid().is_some() {
1104                        self.record_pending_queue_dedup(
1105                            current_connection_id(),
1106                            queue,
1107                            dedup_key,
1108                            metadata_id,
1109                        );
1110                    }
1111                }
1112                // Resolve per-message availability (issue #722): DELAY is
1113                // relative to the push instant, AVAILABLE AT carries an
1114                // absolute unix-ms. Both collapse to a unix-ns timestamp
1115                // delivery paths compare against. `None` means immediate.
1116                let available_at_ns = available.map(|a| match a {
1117                    reddb_rql::ast::QueueAvailability::DelayMs(ms) => {
1118                        now_ns().saturating_add(ms.saturating_mul(1_000_000))
1119                    }
1120                    reddb_rql::ast::QueueAvailability::AtUnixMs(ms) => ms.saturating_mul(1_000_000),
1121                });
1122                if config.ttl_ms.is_some() || available_at_ns.is_some() || key.is_some() {
1123                    store
1124                        .set_metadata(
1125                            queue,
1126                            id,
1127                            queue_message_metadata(config.ttl_ms, available_at_ns, key.as_deref()),
1128                        )
1129                        .map_err(|err| RedDBError::Internal(err.to_string()))?;
1130                }
1131                // Slice C of PRD #718 — wake `QUEUE READ … WAIT` waiters.
1132                // Under autocommit this fires immediately; inside a txn
1133                // the wake is buffered and replayed on COMMIT (rollback
1134                // discards it so rolled-back enqueues do not deliver).
1135                self.record_queue_wake(&queue_wait_scope(), queue);
1136                self.invalidate_result_cache();
1137
1138                let mut result = UnifiedResult::with_columns(vec![
1139                    "message_id".into(),
1140                    "side".into(),
1141                    "queue".into(),
1142                ]);
1143                let mut record = UnifiedRecord::new();
1144                record.set("message_id", Value::text(message_id_string(id)));
1145                record.set(
1146                    "side",
1147                    Value::text(match side {
1148                        QueueSide::Left => "left".to_string(),
1149                        QueueSide::Right => "right".to_string(),
1150                    }),
1151                );
1152                record.set("queue", Value::text(queue.clone()));
1153                result.push(record);
1154
1155                Ok(RuntimeQueryResult {
1156                    query: raw_query.to_string(),
1157                    mode: QueryMode::Sql,
1158                    statement: "queue_push",
1159                    engine: "runtime-queue",
1160                    result,
1161                    affected_rows: 1,
1162                    statement_type: "insert",
1163                    bookmark: None,
1164                    notice: None,
1165                })
1166            }
1167            QueueCommand::Pop { queue, side, count } => {
1168                let store = self.inner.db.store();
1169                ensure_queue_exists(store.as_ref(), queue)?;
1170                let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1171                let popped = lifecycle
1172                    .pop(queue, ast_side_to_lc(*side), *count, &txn)
1173                    .map_err(map_qse)?;
1174
1175                let mut result =
1176                    UnifiedResult::with_columns(vec!["message_id".into(), "payload".into()]);
1177                for (message_id, payload) in &popped {
1178                    let mut record = UnifiedRecord::new();
1179                    record.set(
1180                        "message_id",
1181                        Value::text(message_id_string(EntityId::new(*message_id))),
1182                    );
1183                    record.set("payload", payload.clone());
1184                    result.push(record);
1185                }
1186                let popped_count = popped.len() as u64;
1187                if popped_count > 0 {
1188                    self.invalidate_result_cache();
1189                }
1190
1191                Ok(RuntimeQueryResult {
1192                    query: raw_query.to_string(),
1193                    mode: QueryMode::Sql,
1194                    statement: "queue_pop",
1195                    engine: "runtime-queue",
1196                    result,
1197                    affected_rows: popped_count,
1198                    statement_type: "delete",
1199                    bookmark: None,
1200                    notice: None,
1201                })
1202            }
1203            QueueCommand::Peek { queue, count } => {
1204                let store = self.inner.db.store();
1205                ensure_queue_exists(store.as_ref(), queue)?;
1206                let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1207                let messages = lifecycle.peek(queue, *count, &txn);
1208
1209                let mut result =
1210                    UnifiedResult::with_columns(vec!["message_id".into(), "payload".into()]);
1211                for message in messages {
1212                    let mut record = UnifiedRecord::new();
1213                    record.set(
1214                        "message_id",
1215                        Value::text(message_id_string(EntityId::new(message.message_id))),
1216                    );
1217                    record.set("payload", message.payload);
1218                    result.push(record);
1219                }
1220
1221                Ok(RuntimeQueryResult {
1222                    query: raw_query.to_string(),
1223                    mode: QueryMode::Sql,
1224                    statement: "queue_peek",
1225                    engine: "runtime-queue",
1226                    result,
1227                    affected_rows: 0,
1228                    statement_type: "select",
1229                    bookmark: None,
1230                    notice: None,
1231                })
1232            }
1233            QueueCommand::Len { queue } => {
1234                let store = self.inner.db.store();
1235                ensure_queue_exists(store.as_ref(), queue)?;
1236                let count =
1237                    load_queue_message_views_with_runtime(Some(self), store.as_ref(), queue)?.len()
1238                        as u64;
1239                let mut result = UnifiedResult::with_columns(vec!["len".into()]);
1240                let mut record = UnifiedRecord::new();
1241                record.set("len", Value::UnsignedInteger(count));
1242                result.push(record);
1243
1244                Ok(RuntimeQueryResult {
1245                    query: raw_query.to_string(),
1246                    mode: QueryMode::Sql,
1247                    statement: "queue_len",
1248                    engine: "runtime-queue",
1249                    result,
1250                    affected_rows: 0,
1251                    statement_type: "select",
1252                    bookmark: None,
1253                    notice: None,
1254                })
1255            }
1256            QueueCommand::Purge { queue } => {
1257                let store = self.inner.db.store();
1258                ensure_queue_exists(store.as_ref(), queue)?;
1259                let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1260                let count = lifecycle.purge(queue, &txn).map_err(map_qse)?;
1261                if count > 0 {
1262                    self.invalidate_result_cache();
1263                }
1264
1265                Ok(RuntimeQueryResult::ok_message(
1266                    raw_query.to_string(),
1267                    &format!("{count} messages purged from queue '{queue}'"),
1268                    "delete",
1269                ))
1270            }
1271            QueueCommand::GroupCreate { queue, group } => {
1272                let store = self.inner.db.store();
1273                ensure_queue_exists(store.as_ref(), queue)?;
1274                if queue_group_exists(store.as_ref(), queue, group)? {
1275                    return Ok(RuntimeQueryResult::ok_message(
1276                        raw_query.to_string(),
1277                        &format!(
1278                            "consumer group '{}' already exists on queue '{}'",
1279                            group, queue
1280                        ),
1281                        "create",
1282                    ));
1283                }
1284                save_queue_group(store.as_ref(), queue, group)?;
1285                self.invalidate_result_cache();
1286
1287                Ok(RuntimeQueryResult::ok_message(
1288                    raw_query.to_string(),
1289                    &format!("consumer group '{}' created on queue '{}'", group, queue),
1290                    "create",
1291                ))
1292            }
1293            QueueCommand::GroupRead {
1294                queue,
1295                group,
1296                consumer,
1297                count,
1298                wait_ms,
1299            } => {
1300                let store = self.inner.db.store();
1301                ensure_queue_exists(store.as_ref(), queue)?;
1302                // Slice B of PRD #718: reject `WAIT` issued inside an
1303                // explicit transaction, and reject `WAIT > cap` before
1304                // any waiter is registered. Both checks fire before the
1305                // lifecycle is touched so a refused statement leaves
1306                // no side effects (no group auto-create, no parking).
1307                if let Some(ms) = *wait_ms {
1308                    if self.current_xid().is_some() {
1309                        return Err(RedDBError::Query(
1310                            "QUEUE READ … WAIT is autocommit-only: refusing to park inside an explicit transaction (BEGIN/COMMIT)"
1311                                .to_string(),
1312                        ));
1313                    }
1314                    let cap =
1315                        self.config_u64(QUEUE_MAX_WAIT_MS_CONFIG_KEY, QUEUE_MAX_WAIT_MS_DEFAULT);
1316                    if ms > cap {
1317                        return Err(RedDBError::Query(format!(
1318                            "QUEUE READ … WAIT {ms}ms exceeds server cap {QUEUE_MAX_WAIT_MS_CONFIG_KEY} = {cap}ms"
1319                        )));
1320                    }
1321                }
1322                // Resolve the consumer group up-front so the lifecycle
1323                // sees the same auto-created `_work_default` / fanout
1324                // group the legacy `read_messages` would have minted.
1325                let config = load_queue_config(store.as_ref(), queue);
1326                let group_owned =
1327                    resolve_read_group(store.as_ref(), queue, group.as_deref(), consumer, &config)?;
1328                let group_ref = group_owned.as_str();
1329                let delivered = self
1330                    .group_read_with_optional_wait(queue, group_ref, consumer, *count, *wait_ms)?;
1331
1332                // Issue #742 — record consumer presence on every read,
1333                // including empty returns. Heartbeat-driven aliveness
1334                // is the contract; pending deliveries don't define it.
1335                {
1336                    let lease_count = u32::try_from(delivered.len()).unwrap_or(u32::MAX);
1337                    let now_ns = std::time::SystemTime::now()
1338                        .duration_since(std::time::UNIX_EPOCH)
1339                        .map(|d| d.as_nanos() as u64)
1340                        .unwrap_or(0);
1341                    self.queue_presence().heartbeat(
1342                        queue,
1343                        group_ref,
1344                        consumer,
1345                        lease_count,
1346                        now_ns,
1347                    );
1348                }
1349
1350                let mut result = UnifiedResult::with_columns(vec![
1351                    "message_id".into(),
1352                    "payload".into(),
1353                    "consumer".into(),
1354                    "delivery_count".into(),
1355                    "attempts".into(),
1356                ]);
1357
1358                for message in delivered {
1359                    let mut record = UnifiedRecord::new();
1360                    record.set(
1361                        "message_id",
1362                        Value::text(message_id_string(EntityId::new(message.message_id))),
1363                    );
1364                    record.set("payload", message.payload);
1365                    record.set("consumer", Value::text(message.consumer));
1366                    record.set(
1367                        "delivery_count",
1368                        Value::UnsignedInteger(u64::from(message.delivery_count)),
1369                    );
1370                    record.set(
1371                        "attempts",
1372                        Value::UnsignedInteger(u64::from(message.delivery_count)),
1373                    );
1374                    result.push(record);
1375                }
1376                if !result.records.is_empty() {
1377                    self.invalidate_result_cache();
1378                }
1379
1380                Ok(RuntimeQueryResult {
1381                    query: raw_query.to_string(),
1382                    mode: QueryMode::Sql,
1383                    statement: "queue_group_read",
1384                    engine: "runtime-queue",
1385                    result,
1386                    affected_rows: 0,
1387                    statement_type: "select",
1388                    bookmark: None,
1389                    notice: None,
1390                })
1391            }
1392            QueueCommand::Pending { queue, group } => {
1393                let store = self.inner.db.store();
1394                ensure_queue_exists(store.as_ref(), queue)?;
1395                require_queue_group(store.as_ref(), queue, group)?;
1396                let mut pending = load_pending_entries(store.as_ref(), queue, Some(group), None)?;
1397                pending.sort_by_key(|entry| entry.delivered_at_ns);
1398                let current_time_ns = now_ns();
1399
1400                let mut result = UnifiedResult::with_columns(vec![
1401                    "message_id".into(),
1402                    "consumer".into(),
1403                    "delivered_at_ns".into(),
1404                    "delivery_count".into(),
1405                    "idle_ms".into(),
1406                ]);
1407                for entry in pending {
1408                    let mut record = UnifiedRecord::new();
1409                    record.set(
1410                        "message_id",
1411                        Value::text(message_id_string(entry.message_id)),
1412                    );
1413                    record.set("consumer", Value::text(entry.consumer));
1414                    record.set(
1415                        "delivered_at_ns",
1416                        Value::UnsignedInteger(entry.delivered_at_ns),
1417                    );
1418                    record.set(
1419                        "delivery_count",
1420                        Value::UnsignedInteger(u64::from(entry.delivery_count)),
1421                    );
1422                    record.set(
1423                        "idle_ms",
1424                        Value::UnsignedInteger(
1425                            current_time_ns.saturating_sub(entry.delivered_at_ns) / 1_000_000,
1426                        ),
1427                    );
1428                    result.push(record);
1429                }
1430
1431                Ok(RuntimeQueryResult {
1432                    query: raw_query.to_string(),
1433                    mode: QueryMode::Sql,
1434                    statement: "queue_pending",
1435                    engine: "runtime-queue",
1436                    result,
1437                    affected_rows: 0,
1438                    statement_type: "select",
1439                    bookmark: None,
1440                    notice: None,
1441                })
1442            }
1443            QueueCommand::Claim {
1444                queue,
1445                group,
1446                consumer,
1447                min_idle_ms,
1448            } => {
1449                let store = self.inner.db.store();
1450                ensure_queue_exists(store.as_ref(), queue)?;
1451                require_queue_group(store.as_ref(), queue, group)?;
1452                let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1453                let delivered = lifecycle
1454                    .claim_delivering(queue, consumer, *min_idle_ms, &txn)
1455                    .map_err(map_qse)?;
1456
1457                let mut result = UnifiedResult::with_columns(vec![
1458                    "message_id".into(),
1459                    "delivery_id".into(),
1460                    "payload".into(),
1461                    "consumer".into(),
1462                    "delivery_count".into(),
1463                ]);
1464
1465                for message in delivered {
1466                    let mut record = UnifiedRecord::new();
1467                    record.set(
1468                        "message_id",
1469                        Value::text(message_id_string(EntityId::new(message.message_id))),
1470                    );
1471                    record.set("delivery_id", Value::text(message.delivery_id));
1472                    record.set("payload", message.payload);
1473                    record.set("consumer", Value::text(message.consumer));
1474                    record.set(
1475                        "delivery_count",
1476                        Value::UnsignedInteger(u64::from(message.delivery_count)),
1477                    );
1478                    result.push(record);
1479                }
1480                if !result.records.is_empty() {
1481                    self.invalidate_result_cache();
1482                }
1483                let affected_rows = result.records.len() as u64;
1484
1485                Ok(RuntimeQueryResult {
1486                    query: raw_query.to_string(),
1487                    mode: QueryMode::Sql,
1488                    statement: "queue_claim",
1489                    engine: "runtime-queue",
1490                    result,
1491                    affected_rows,
1492                    statement_type: "update",
1493                    bookmark: None,
1494                    notice: None,
1495                })
1496            }
1497            QueueCommand::Ack {
1498                queue,
1499                group,
1500                message_id,
1501                delivery_id,
1502            } => {
1503                let store = self.inner.db.store();
1504                ensure_queue_exists(store.as_ref(), queue)?;
1505                let (group_owned, message_entity) = resolve_ack_nack_handle(
1506                    store.as_ref(),
1507                    queue,
1508                    group,
1509                    message_id,
1510                    delivery_id.as_deref(),
1511                )?;
1512                let group_ref = group_owned.as_str();
1513                require_queue_group(store.as_ref(), queue, group_ref)?;
1514                let (lifecycle, ps, txn) = runtime_lifecycle(self, queue);
1515                let did = match delivery_id.as_deref() {
1516                    Some(d) => d.to_string(),
1517                    None => ps
1518                        .find_pending_by_key(queue, message_entity.raw(), group_ref)
1519                        .ok_or_else(|| {
1520                            RedDBError::NotFound(format!(
1521                                "no pending delivery for message '{}' on queue '{}' (group '{}')",
1522                                message_entity.raw(),
1523                                queue,
1524                                group_ref
1525                            ))
1526                        })?,
1527                };
1528                lifecycle.ack(&txn, &did).map_err(map_qse)?;
1529                self.invalidate_result_cache();
1530
1531                Ok(RuntimeQueryResult::ok_message(
1532                    raw_query.to_string(),
1533                    "message acknowledged",
1534                    "update",
1535                ))
1536            }
1537            QueueCommand::Nack {
1538                queue,
1539                group,
1540                message_id,
1541                delivery_id,
1542                delay_ms,
1543            } => {
1544                let store = self.inner.db.store();
1545                ensure_queue_exists(store.as_ref(), queue)?;
1546                let config = load_queue_config(store.as_ref(), queue);
1547                // Issue #723: a per-failure DELAY override is a write
1548                // operation that re-shapes retry behavior; readers must
1549                // not be able to silently re-schedule another worker's
1550                // work. Embedded callers (no auth identity attached)
1551                // are trusted and bypass the check.
1552                if delay_ms.is_some() {
1553                    if let Some((_, role)) = current_auth_identity() {
1554                        if !role.can_write() {
1555                            return Err(RedDBError::InvalidOperation(format!(
1556                                "role '{role}' is not authorized to override NACK retry delay on queue '{queue}'"
1557                            )));
1558                        }
1559                    }
1560                }
1561                let (group_owned, message_entity) = resolve_ack_nack_handle(
1562                    store.as_ref(),
1563                    queue,
1564                    group,
1565                    message_id,
1566                    delivery_id.as_deref(),
1567                )?;
1568                let group_ref = group_owned.as_str();
1569                require_queue_group(store.as_ref(), queue, group_ref)?;
1570                let (lifecycle, ps, txn) = runtime_lifecycle(self, queue);
1571                let did = match delivery_id.as_deref() {
1572                    Some(d) => d.to_string(),
1573                    None => ps
1574                        .find_pending_by_key(queue, message_entity.raw(), group_ref)
1575                        .ok_or_else(|| {
1576                            RedDBError::NotFound(format!(
1577                                "no pending delivery for message '{}' on queue '{}' (group '{}')",
1578                                message_entity.raw(),
1579                                queue,
1580                                group_ref
1581                            ))
1582                        })?,
1583                };
1584                // Resolve the effective retry delay: per-failure
1585                // override wins, then queue default, then zero
1586                // (immediate requeue — pre-#723 behavior).
1587                let effective_delay_ms = delay_ms.or(config.retry_delay_ms).unwrap_or(0);
1588                let pending_attempt = ps.read_pending_attempt(&did).map_err(map_qse)?;
1589                let nack_attempts = pending_attempt.attempts.saturating_add(1);
1590                let retry_available_at_ns = if effective_delay_ms > 0 {
1591                    Some(now_ns().saturating_add(effective_delay_ms.saturating_mul(1_000_000)))
1592                } else {
1593                    None
1594                };
1595                let retry_deadline = if effective_delay_ms > 0 {
1596                    Some(
1597                        std::time::Instant::now()
1598                            + std::time::Duration::from_millis(effective_delay_ms),
1599                    )
1600                } else {
1601                    None
1602                };
1603                let outcome = lifecycle
1604                    .nack_with_retry_deadline(&txn, &did, retry_deadline)
1605                    .map_err(map_qse)?;
1606                // Apply delay only when the message was actually
1607                // requeued — DLQ promotion / drop terminate the
1608                // retry cycle and a delay would be meaningless.
1609                if matches!(outcome, RetirementOutcome::Requeued) && effective_delay_ms > 0 {
1610                    set_message_available_at_ns(
1611                        store.as_ref(),
1612                        queue,
1613                        message_entity,
1614                        retry_available_at_ns,
1615                        config.ttl_ms,
1616                    )?;
1617                }
1618                // Issue #723: routine retries do not flood audit
1619                // channels (telemetry already covers them via
1620                // `queue_nacked_total{outcome=...}`). Significant
1621                // overrides — large delays, destination changes,
1622                // drops — are audited so operators see the events
1623                // that re-shape operational risk.
1624                self.maybe_emit_nack_audit(
1625                    queue,
1626                    group_ref,
1627                    &did,
1628                    *delay_ms,
1629                    config.retry_delay_ms,
1630                    &outcome,
1631                );
1632                let outcome_label = match &outcome {
1633                    RetirementOutcome::Requeued => NackOutcomeLabel::Retry,
1634                    RetirementOutcome::MovedToDlq(_) => NackOutcomeLabel::Dlq,
1635                    RetirementOutcome::Dropped => NackOutcomeLabel::Drop,
1636                };
1637                self.queue_telemetry().record_nacked(
1638                    queue,
1639                    group_ref,
1640                    config.mode.as_str(),
1641                    outcome_label,
1642                );
1643                if let RetirementOutcome::MovedToDlq(dlq) = &outcome {
1644                    OperatorEvent::QueueDlqPromoted {
1645                        queue: queue.to_string(),
1646                        group: group_ref.to_string(),
1647                        dlq: dlq.clone(),
1648                        message_id: pending_attempt.message_id,
1649                        attempts: nack_attempts,
1650                        reason: format!("lifecycle_nack:{did}"),
1651                    }
1652                    .emit(self.audit_log());
1653                }
1654                let message = match outcome {
1655                    RetirementOutcome::Requeued => {
1656                        if effective_delay_ms > 0 {
1657                            format!("message requeued (delay={effective_delay_ms}ms)")
1658                        } else {
1659                            "message requeued".to_string()
1660                        }
1661                    }
1662                    RetirementOutcome::MovedToDlq(dlq) => {
1663                        format!("message moved to dead-letter queue '{}'", dlq)
1664                    }
1665                    RetirementOutcome::Dropped => "message dropped after max attempts".to_string(),
1666                };
1667                self.invalidate_result_cache();
1668
1669                Ok(RuntimeQueryResult::ok_message(
1670                    raw_query.to_string(),
1671                    &message,
1672                    "update",
1673                ))
1674            }
1675            QueueCommand::Move {
1676                source,
1677                destination,
1678                filter,
1679                limit,
1680            } => self.execute_queue_move(raw_query, source, destination, filter.as_ref(), *limit),
1681        }
1682    }
1683
1684    pub fn execute_queue_select(
1685        &self,
1686        raw_query: &str,
1687        query: &QueueSelectQuery,
1688    ) -> RedDBResult<RuntimeQueryResult> {
1689        let store = self.inner.db.store();
1690        ensure_queue_exists(store.as_ref(), &query.queue)?;
1691        let config = load_queue_config(store.as_ref(), &query.queue);
1692        let dlq = queue_is_dead_letter_target(store.as_ref(), &query.queue);
1693        let columns = if query.columns.is_empty() {
1694            queue_projection_default_columns()
1695        } else {
1696            query.columns.clone()
1697        };
1698
1699        let mut messages =
1700            load_queue_message_views_with_runtime(Some(self), store.as_ref(), &query.queue)?;
1701        sort_queue_messages(&mut messages, &config, QueueSide::Left);
1702
1703        let mut result = UnifiedResult::with_columns(columns.clone());
1704        for message in messages {
1705            if query
1706                .filter
1707                .as_ref()
1708                .is_some_and(|filter| !queue_message_matches_filter(&message, dlq, filter))
1709            {
1710                continue;
1711            }
1712            let record = queue_projection_record(&columns, &message, dlq)?;
1713            result.push(record);
1714            if query
1715                .limit
1716                .is_some_and(|limit| result.records.len() >= limit as usize)
1717            {
1718                break;
1719            }
1720        }
1721
1722        Ok(RuntimeQueryResult {
1723            query: raw_query.to_string(),
1724            mode: QueryMode::Sql,
1725            statement: "queue_select",
1726            engine: "runtime-queue",
1727            result,
1728            affected_rows: 0,
1729            statement_type: "select",
1730            bookmark: None,
1731            notice: None,
1732        })
1733    }
1734
1735    fn execute_queue_move(
1736        &self,
1737        raw_query: &str,
1738        source: &str,
1739        destination: &str,
1740        filter: Option<&Filter>,
1741        limit: usize,
1742    ) -> RedDBResult<RuntimeQueryResult> {
1743        if source == destination {
1744            return Err(RedDBError::Query(
1745                "QUEUE MOVE source and destination must be different".to_string(),
1746            ));
1747        }
1748        let store = self.inner.db.store();
1749        ensure_queue_exists(store.as_ref(), source)?;
1750        ensure_queue_exists(store.as_ref(), destination)?;
1751        let source_config = load_queue_config(store.as_ref(), source);
1752        let destination_config = load_queue_config(store.as_ref(), destination);
1753        let source_dlq = queue_is_dead_letter_target(store.as_ref(), source);
1754
1755        let mut messages =
1756            load_queue_message_views_with_runtime(Some(self), store.as_ref(), source)?;
1757        sort_queue_messages(&mut messages, &source_config, QueueSide::Left);
1758        let selected = messages
1759            .into_iter()
1760            .filter(|message| {
1761                filter
1762                    .map(|f| queue_message_matches_filter(message, source_dlq, f))
1763                    .unwrap_or(true)
1764            })
1765            .take(limit)
1766            .collect::<Vec<_>>();
1767
1768        if let Some(max_size) = destination_config.max_size {
1769            let current_len =
1770                load_queue_message_views_with_runtime(Some(self), store.as_ref(), destination)?
1771                    .len();
1772            if current_len + selected.len() > max_size {
1773                return Err(RedDBError::Query(format!(
1774                    "queue '{}' is full (max_size={max_size})",
1775                    destination
1776                )));
1777            }
1778        }
1779
1780        for message in &selected {
1781            let lock = queue_message_lock_handle(self, source, message.id);
1782            let Some(_guard) = lock.try_lock() else {
1783                return Err(RedDBError::Query(format!(
1784                    "message '{}' is locked on queue '{}'",
1785                    message.id.raw(),
1786                    source
1787                )));
1788            };
1789            if queue_message_view_by_id(store.as_ref(), source, message.id)?.is_none() {
1790                return Err(RedDBError::Query(format!(
1791                    "message '{}' is no longer available on queue '{}'",
1792                    message.id.raw(),
1793                    source
1794                )));
1795            }
1796        }
1797
1798        let mut inserted = Vec::new();
1799        for message in &selected {
1800            match insert_moved_queue_message(
1801                store.as_ref(),
1802                destination,
1803                &destination_config,
1804                message,
1805            ) {
1806                Ok(id) => inserted.push(id),
1807                Err(err) => {
1808                    for id in inserted {
1809                        let _ = store.delete(destination, id);
1810                    }
1811                    return Err(err);
1812                }
1813            }
1814        }
1815
1816        let (move_lifecycle, _move_ps, move_txn) = runtime_lifecycle(self, source);
1817        for message in &selected {
1818            move_lifecycle
1819                .delete_with_state(source, message.id.raw(), &move_txn)
1820                .map_err(map_qse)?;
1821        }
1822        if !selected.is_empty() {
1823            self.invalidate_result_cache();
1824        }
1825
1826        let selected_count = selected.len() as u64;
1827        self.audit_log().record_event(
1828            AuditEvent::builder("queue/move")
1829                .source(AuditAuthSource::System)
1830                .outcome(Outcome::Success)
1831                .resource(format!("queue:{source}->{destination}"))
1832                .fields([
1833                    AuditFieldEscaper::field("source", source),
1834                    AuditFieldEscaper::field("destination", destination),
1835                    AuditFieldEscaper::field("selected", selected_count),
1836                    AuditFieldEscaper::field("committed", selected_count),
1837                ])
1838                .build(),
1839        );
1840
1841        let mut result = UnifiedResult::with_columns(vec![
1842            "source".into(),
1843            "destination".into(),
1844            "selected".into(),
1845            "committed".into(),
1846        ]);
1847        let mut record = UnifiedRecord::new();
1848        record.set("source", Value::text(source.to_string()));
1849        record.set("destination", Value::text(destination.to_string()));
1850        record.set("selected", Value::UnsignedInteger(selected_count));
1851        record.set("committed", Value::UnsignedInteger(selected_count));
1852        result.push(record);
1853
1854        Ok(RuntimeQueryResult {
1855            query: raw_query.to_string(),
1856            mode: QueryMode::Sql,
1857            statement: "queue_move",
1858            engine: "runtime-queue",
1859            result,
1860            affected_rows: selected_count,
1861            statement_type: "update",
1862            bookmark: None,
1863            notice: None,
1864        })
1865    }
1866
1867    /// Issue #723: routine retries are observable through metrics
1868    /// (`queue_nacked_total{outcome=...}`) so this is the audit
1869    /// shoulder for the *non-routine* cases: explicit NACK delay
1870    /// overrides whose magnitude or destination-changing impact would
1871    /// be invisible in metrics alone. Specifically:
1872    ///
1873    /// - Explicit override ≥ 60s (a worker decided to defer well past
1874    ///   the queue's default cadence — operators care).
1875    /// - Override that lands on a DLQ promotion or drop (destination
1876    ///   changed; the override may have influenced retire-vs-requeue
1877    ///   accounting on the caller's side and the audit trail needs to
1878    ///   show who asked).
1879    ///
1880    /// Calls with no override are intentionally silent here.
1881    fn maybe_emit_nack_audit(
1882        &self,
1883        queue: &str,
1884        group: &str,
1885        delivery_id: &str,
1886        override_ms: Option<u64>,
1887        default_ms: Option<u64>,
1888        outcome: &RetirementOutcome,
1889    ) {
1890        let Some(override_ms) = override_ms else {
1891            return;
1892        };
1893        let outcome_label = match outcome {
1894            RetirementOutcome::Requeued => "requeued",
1895            RetirementOutcome::MovedToDlq(_) => "dlq",
1896            RetirementOutcome::Dropped => "dropped",
1897        };
1898        const SIGNIFICANT_DELAY_MS: u64 = 60_000;
1899        let destination_changed = !matches!(outcome, RetirementOutcome::Requeued);
1900        if override_ms < SIGNIFICANT_DELAY_MS && !destination_changed {
1901            return;
1902        }
1903        self.audit_log().record_event(
1904            AuditEvent::builder("queue/nack/override")
1905                .source(AuditAuthSource::System)
1906                .outcome(Outcome::Success)
1907                .resource(format!("queue:{queue}"))
1908                .fields([
1909                    AuditFieldEscaper::field("queue", queue),
1910                    AuditFieldEscaper::field("group", group),
1911                    AuditFieldEscaper::field("delivery_id", delivery_id),
1912                    AuditFieldEscaper::field("override_delay_ms", override_ms),
1913                    AuditFieldEscaper::field("default_delay_ms", default_ms.unwrap_or(0)),
1914                    AuditFieldEscaper::field("outcome", outcome_label),
1915                ])
1916                .build(),
1917        );
1918    }
1919
1920    /// Whether `collection` is declared as a queue model. Used to route a
1921    /// `CLAIM` through the [`QueueLifecycle`] seam instead of the raw
1922    /// row-update path (#1609).
1923    pub(super) fn is_queue_collection(&self, collection: &str) -> bool {
1924        self.db()
1925            .collection_contract_arc(collection)
1926            .map(|contract| contract.declared_model == crate::catalog::CollectionModel::Queue)
1927            .unwrap_or(false)
1928    }
1929
1930    /// Route a queue-collection `CLAIM` through the [`QueueLifecycle`]
1931    /// delivery seam (ADR 0020, #1609).
1932    ///
1933    /// A `CLAIM` on a queue collection is a delivery *acquisition* — select
1934    /// and lock pending messages — not a raw UPDATE of the underlying queue
1935    /// rows. Dispatching here keeps the `QueueLifecycle` state machine
1936    /// (ACK/NACK, retry, DLQ, pending delivery) the sole
1937    /// authority for delivery state, instead of `execute_update_inner_tracked`
1938    /// mutating queue storage directly.
1939    ///
1940    /// Shapes the delivery seam cannot express are rejected up front with a
1941    /// clear [`RedDBError::InvalidOperation`] rather than silently falling
1942    /// back to raw storage mutation (see [`validate_queue_claim_shape`]).
1943    pub(super) fn execute_queue_shaped_claim(
1944        &self,
1945        raw_query: &str,
1946        query: &UpdateQuery,
1947    ) -> RedDBResult<RuntimeQueryResult> {
1948        validate_queue_claim_shape(query)?;
1949
1950        let queue = query.table.as_str();
1951        let store = self.inner.db.store();
1952        ensure_queue_exists(store.as_ref(), queue)?;
1953
1954        // A queue-shaped CLAIM maps onto WORK-mode delivery: each message is
1955        // reserved for exactly one consumer. FANOUT queues fan every message
1956        // to every group, which a single-target CLAIM cannot express — those
1957        // must be consumed through GROUP READ.
1958        let config = load_queue_config(store.as_ref(), queue);
1959        if config.mode != QueueMode::Work {
1960            return Err(RedDBError::InvalidOperation(format!(
1961                "CLAIM on queue '{queue}' cannot be expressed: FANOUT delivery \
1962                 must be consumed through GROUP READ, not a queue-shaped CLAIM"
1963            )));
1964        }
1965
1966        // Attribute the delivery to the WORK default consumer group (the same
1967        // group an unqualified `QUEUE READ` uses), so the pending locks the
1968        // lifecycle records resolve for a later ACK/NACK by delivery id.
1969        let group = resolve_read_group(store.as_ref(), queue, None, "", &config)?;
1970        let count = query.claim_limit.unwrap_or(0) as usize;
1971        let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1972        let delivered = lifecycle
1973            .deliver(&txn, queue, &group, count)
1974            .map_err(map_qse)?;
1975
1976        let mut result = UnifiedResult::with_columns(vec!["delivery_id".into(), "payload".into()]);
1977        for message in &delivered {
1978            let mut record = UnifiedRecord::new();
1979            record.set("delivery_id", Value::text(message.delivery_id.clone()));
1980            record.set("payload", message.payload.clone());
1981            result.push(record);
1982        }
1983        let affected_rows = delivered.len() as u64;
1984        if affected_rows > 0 {
1985            self.invalidate_result_cache();
1986        }
1987
1988        Ok(RuntimeQueryResult {
1989            query: raw_query.to_string(),
1990            mode: QueryMode::Sql,
1991            statement: "queue_claim",
1992            engine: "runtime-queue",
1993            result,
1994            affected_rows,
1995            statement_type: "update",
1996            bookmark: None,
1997            notice: None,
1998        })
1999    }
2000}
2001
2002/// Reject `CLAIM` shapes that [`QueueLifecycle`] delivery cannot express
2003/// (#1609). Queue delivery is strictly FIFO (oldest-available first) and
2004/// acquires *up to* N available messages:
2005///
2006/// - a descending `ORDER BY` contradicts FIFO delivery order, and
2007/// - `CLAIM EXACT` demands an all-or-nothing batch the delivery seam does
2008///   not offer.
2009///
2010/// Both surface a clear [`RedDBError::InvalidOperation`] instead of a
2011/// silent raw storage mutation.
2012fn validate_queue_claim_shape(query: &UpdateQuery) -> RedDBResult<()> {
2013    if query.order_by.iter().any(|clause| !clause.ascending) {
2014        return Err(RedDBError::InvalidOperation(format!(
2015            "CLAIM on queue '{}' cannot be expressed: a descending ORDER BY \
2016             conflicts with FIFO queue delivery order",
2017            query.table
2018        )));
2019    }
2020    if query.claim_exact {
2021        return Err(RedDBError::InvalidOperation(format!(
2022            "CLAIM EXACT on queue '{}' cannot be expressed: queue delivery \
2023             acquires up to N available messages, not an exact-or-nothing batch",
2024            query.table
2025        )));
2026    }
2027    Ok(())
2028}
2029
2030fn ensure_queue_exists(store: &UnifiedStore, queue: &str) -> RedDBResult<()> {
2031    if store.get_collection(queue).is_some() {
2032        Ok(())
2033    } else {
2034        Err(RedDBError::NotFound(format!("queue '{}' not found", queue)))
2035    }
2036}
2037
2038pub(super) fn load_queue_config(store: &UnifiedStore, queue: &str) -> QueueRuntimeConfig {
2039    let default = QueueRuntimeConfig {
2040        mode: QueueMode::Work,
2041        priority: false,
2042        max_size: None,
2043        ttl_ms: None,
2044        dlq: None,
2045        max_attempts: reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS,
2046        lock_deadline_ms: reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS,
2047        in_flight_cap_per_group: reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP,
2048        retry_delay_ms: None,
2049        dedup_window_ms: None,
2050    };
2051
2052    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2053        return default;
2054    };
2055    let rows = match super::queue_storage_index::subject_rows_store(
2056        store,
2057        "queue_config",
2058        queue,
2059        None,
2060        None,
2061    ) {
2062        Ok(Some(rows)) => rows,
2063        Ok(None) | Err(_) => manager
2064            .query_all(|entity| {
2065                entity.data.as_row().is_some_and(|row| {
2066                    row_text(row, "kind").as_deref() == Some("queue_config")
2067                        && row_text(row, "queue").as_deref() == Some(queue)
2068                })
2069            })
2070            .into_iter()
2071            .filter_map(|entity| match entity.data {
2072                EntityData::Row(row) => Some((entity.id, row)),
2073                _ => None,
2074            })
2075            .collect(),
2076    };
2077    rows.into_iter()
2078        .next()
2079        .map(|(_, row)| QueueRuntimeConfig {
2080            mode: row_text(&row, "mode")
2081                .as_deref()
2082                .and_then(QueueMode::parse)
2083                .unwrap_or_default(),
2084            priority: row_bool(&row, "priority").unwrap_or(false),
2085            max_size: row_u64(&row, "max_size").map(|value| value as usize),
2086            ttl_ms: row_u64(&row, "ttl_ms"),
2087            dlq: row_text(&row, "dlq"),
2088            max_attempts: row_u64(&row, "max_attempts")
2089                .map(|value| value as u32)
2090                .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS),
2091            lock_deadline_ms: row_u64(&row, "lock_deadline_ms")
2092                .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS),
2093            in_flight_cap_per_group: row_u64(&row, "in_flight_cap_per_group")
2094                .map(|value| value as u32)
2095                .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP),
2096            retry_delay_ms: row_u64(&row, "retry_delay_ms").filter(|v| *v > 0),
2097            dedup_window_ms: row_u64(&row, "dedup_window_ms"),
2098        })
2099        .unwrap_or(default)
2100}
2101
2102pub(super) fn queue_mode_str(store: &UnifiedStore, queue: &str) -> &'static str {
2103    load_queue_config(store, queue).mode.as_str()
2104}
2105
2106fn save_queue_config(
2107    store: &UnifiedStore,
2108    queue: &str,
2109    config: &QueueRuntimeConfig,
2110) -> RedDBResult<()> {
2111    match super::queue_storage_index::subject_rows_store(store, "queue_config", queue, None, None)?
2112    {
2113        Some(rows) => {
2114            for (id, _) in rows {
2115                let _ = store.delete(QUEUE_META_COLLECTION, id);
2116            }
2117        }
2118        None => remove_meta_rows(store, |row| {
2119            row_text(row, "kind").as_deref() == Some("queue_config")
2120                && row_text(row, "queue").as_deref() == Some(queue)
2121        }),
2122    }
2123
2124    let mut fields = HashMap::new();
2125    fields.insert("kind".to_string(), Value::text("queue_config".to_string()));
2126    fields.insert("queue".to_string(), Value::text(queue.to_string()));
2127    fields.insert(
2128        "mode".to_string(),
2129        Value::text(config.mode.as_str().to_string()),
2130    );
2131    fields.insert("priority".to_string(), Value::Boolean(config.priority));
2132    fields.insert(
2133        "max_size".to_string(),
2134        config
2135            .max_size
2136            .map(|value| Value::UnsignedInteger(value as u64))
2137            .unwrap_or(Value::Null),
2138    );
2139    fields.insert(
2140        "ttl_ms".to_string(),
2141        config
2142            .ttl_ms
2143            .map(Value::UnsignedInteger)
2144            .unwrap_or(Value::Null),
2145    );
2146    fields.insert(
2147        "dlq".to_string(),
2148        config.dlq.clone().map(Value::text).unwrap_or(Value::Null),
2149    );
2150    fields.insert(
2151        "max_attempts".to_string(),
2152        Value::UnsignedInteger(u64::from(config.max_attempts)),
2153    );
2154    fields.insert(
2155        "lock_deadline_ms".to_string(),
2156        Value::UnsignedInteger(config.lock_deadline_ms),
2157    );
2158    fields.insert(
2159        "in_flight_cap_per_group".to_string(),
2160        Value::UnsignedInteger(u64::from(config.in_flight_cap_per_group)),
2161    );
2162    fields.insert(
2163        "retry_delay_ms".to_string(),
2164        config
2165            .retry_delay_ms
2166            .map(Value::UnsignedInteger)
2167            .unwrap_or(Value::Null),
2168    );
2169    fields.insert(
2170        "dedup_window_ms".to_string(),
2171        config
2172            .dedup_window_ms
2173            .map(Value::UnsignedInteger)
2174            .unwrap_or(Value::Null),
2175    );
2176    insert_meta_row(store, fields).map(|_| ())
2177}
2178
2179fn remove_queue_metadata(store: &UnifiedStore, queue: &str) {
2180    remove_meta_rows(store, |row| {
2181        row_text(row, "queue").as_deref() == Some(queue)
2182    });
2183}
2184
2185fn queue_group_exists(store: &UnifiedStore, queue: &str, group: &str) -> RedDBResult<bool> {
2186    Ok(load_queue_groups(store, queue)?
2187        .into_iter()
2188        .any(|entry| entry.group == group))
2189}
2190
2191pub(super) fn require_queue_group(
2192    store: &UnifiedStore,
2193    queue: &str,
2194    group: &str,
2195) -> RedDBResult<()> {
2196    if queue_group_exists(store, queue, group)? {
2197        Ok(())
2198    } else {
2199        Err(RedDBError::NotFound(format!(
2200            "consumer group '{}' not found on queue '{}'",
2201            group, queue
2202        )))
2203    }
2204}
2205
2206pub(super) fn resolve_read_group(
2207    store: &UnifiedStore,
2208    queue: &str,
2209    group: Option<&str>,
2210    consumer: &str,
2211    config: &QueueRuntimeConfig,
2212) -> RedDBResult<String> {
2213    if let Some(group) = group {
2214        require_queue_group(store, queue, group)?;
2215        return Ok(group.to_string());
2216    }
2217
2218    match config.mode {
2219        QueueMode::Work => {
2220            if !queue_group_exists(store, queue, WORK_DEFAULT_GROUP)? {
2221                save_queue_group(store, queue, WORK_DEFAULT_GROUP)?;
2222            }
2223            Ok(WORK_DEFAULT_GROUP.to_string())
2224        }
2225        QueueMode::Fanout => {
2226            let fanout_group = format!("{FANOUT_GROUP_PREFIX}{consumer}");
2227            if !queue_group_exists(store, queue, &fanout_group)? {
2228                save_queue_group(store, queue, &fanout_group)?;
2229            }
2230            Ok(fanout_group)
2231        }
2232    }
2233}
2234
2235fn load_queue_groups(store: &UnifiedStore, queue: &str) -> RedDBResult<Vec<QueueGroupEntry>> {
2236    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2237        return Ok(Vec::new());
2238    };
2239    if let Some(rows) =
2240        super::queue_storage_index::subject_rows_store(store, "queue_group", queue, None, None)?
2241    {
2242        return Ok(rows
2243            .into_iter()
2244            .filter_map(|(entity_id, row)| {
2245                Some(QueueGroupEntry {
2246                    entity_id,
2247                    group: row_text(&row, "group")?,
2248                })
2249            })
2250            .collect());
2251    }
2252    let _publication = manager
2253        .has_backing()
2254        .then(|| store.vault_publication_lock.lock());
2255    let mut groups = Vec::new();
2256    super::function_budget::try_scan(
2257        |visit| manager.try_for_each_entity(usize::MAX, visit),
2258        |entity| {
2259            let Some(row) = entity.data.as_row() else {
2260                return true;
2261            };
2262            if row_text(row, "kind").as_deref() == Some("queue_group")
2263                && row_text(row, "queue").as_deref() == Some(queue)
2264            {
2265                let Some(group) = row_text(row, "group") else {
2266                    return true;
2267                };
2268                groups.push(QueueGroupEntry {
2269                    entity_id: entity.id,
2270                    group,
2271                });
2272            }
2273            true
2274        },
2275    )?;
2276    Ok(groups)
2277}
2278
2279fn save_queue_group(store: &UnifiedStore, queue: &str, group: &str) -> RedDBResult<()> {
2280    let mut fields = HashMap::new();
2281    fields.insert("kind".to_string(), Value::text("queue_group".to_string()));
2282    fields.insert("queue".to_string(), Value::text(queue.to_string()));
2283    fields.insert("group".to_string(), Value::text(group.to_string()));
2284    fields.insert(
2285        "created_at_ns".to_string(),
2286        Value::UnsignedInteger(now_ns()),
2287    );
2288    insert_meta_row(store, fields).map(|_| ())
2289}
2290
2291pub(super) fn load_pending_entries(
2292    store: &UnifiedStore,
2293    queue: &str,
2294    group: Option<&str>,
2295    message_id: Option<EntityId>,
2296) -> RedDBResult<Vec<QueuePendingEntry>> {
2297    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2298        return Ok(Vec::new());
2299    };
2300    let lock_deadline_ns = load_queue_config(store, queue)
2301        .lock_deadline_ms
2302        .saturating_mul(1_000_000);
2303    let indexed_attempts = super::queue_storage_index::subject_rows_store(
2304        store,
2305        "queue_attempts_lc",
2306        queue,
2307        group,
2308        message_id.map(|id| id.raw()),
2309    )?;
2310    let attempts = indexed_attempts.unwrap_or_else(|| {
2311        manager
2312            .query_all(|entity| {
2313                entity.data.as_row().is_some_and(|row| {
2314                    row_text(row, "kind").as_deref() == Some("queue_attempts_lc")
2315                        && row_text(row, "queue").as_deref() == Some(queue)
2316                })
2317            })
2318            .into_iter()
2319            .filter_map(|entity| match entity.data {
2320                EntityData::Row(row) => Some((entity.id, row)),
2321                _ => None,
2322            })
2323            .collect()
2324    });
2325    let attempts_by_key: HashMap<(String, String, u64), u64> = attempts
2326        .into_iter()
2327        .filter_map(|(_, row)| {
2328            Some((
2329                (
2330                    row_text(&row, "queue")?,
2331                    row_text(&row, "group")?,
2332                    row_u64(&row, "message_id")?,
2333                ),
2334                row_u64(&row, "attempts").unwrap_or(1),
2335            ))
2336        })
2337        .collect();
2338    let mut pending = Vec::new();
2339    for kind in ["queue_pending", "queue_pending_lc"] {
2340        match super::queue_storage_index::subject_rows_store(
2341            store,
2342            kind,
2343            queue,
2344            group,
2345            message_id.map(|id| id.raw()),
2346        )? {
2347            Some(rows) => pending.extend(rows),
2348            None => pending.extend(
2349                manager
2350                    .query_all(|entity| {
2351                        entity.data.as_row().is_some_and(|row| {
2352                            row_text(row, "kind").as_deref() == Some(kind)
2353                                && row_text(row, "queue").as_deref() == Some(queue)
2354                                && group
2355                                    .map(|group_name| {
2356                                        row_text(row, "group").as_deref() == Some(group_name)
2357                                    })
2358                                    .unwrap_or(true)
2359                                && message_id
2360                                    .map(|candidate| {
2361                                        row_u64(row, "message_id") == Some(candidate.raw())
2362                                    })
2363                                    .unwrap_or(true)
2364                        })
2365                    })
2366                    .into_iter()
2367                    .filter_map(|entity| match entity.data {
2368                        EntityData::Row(row) => Some((entity.id, row)),
2369                        _ => None,
2370                    }),
2371            ),
2372        }
2373    }
2374    pending.sort_unstable_by_key(|(id, _)| id.raw());
2375    Ok(pending
2376        .into_iter()
2377        .filter_map(|(entity_id, row)| {
2378            let group = row_text(&row, "group")?;
2379            let message_id = row_u64(&row, "message_id")?;
2380            let kind = row_text(&row, "kind")?;
2381            let delivered_at_ns = if kind == "queue_pending_lc" {
2382                row_u64(&row, "lock_deadline_ns")
2383                    .unwrap_or(0)
2384                    .saturating_sub(lock_deadline_ns)
2385            } else {
2386                row_u64(&row, "delivered_at_ns")?
2387            };
2388            let delivery_count = if kind == "queue_pending_lc" {
2389                attempts_by_key
2390                    .get(&(queue.to_string(), group.clone(), message_id))
2391                    .copied()
2392                    .unwrap_or(1)
2393            } else {
2394                row_u64(&row, "delivery_count").unwrap_or(1)
2395            };
2396            Some(QueuePendingEntry {
2397                entity_id,
2398                group,
2399                message_id: EntityId::new(message_id),
2400                consumer: row_text(&row, "consumer").unwrap_or_default(),
2401                delivered_at_ns,
2402                delivery_count: delivery_count as u32,
2403            })
2404        })
2405        .collect())
2406}
2407
2408pub(super) fn save_queue_pending(
2409    store: &UnifiedStore,
2410    queue: &str,
2411    group: &str,
2412    message_id: EntityId,
2413    consumer: &str,
2414    delivered_at_ns: u64,
2415    delivery_count: u32,
2416) -> RedDBResult<()> {
2417    match super::queue_storage_index::subject_rows_store(
2418        store,
2419        "queue_pending",
2420        queue,
2421        Some(group),
2422        Some(message_id.raw()),
2423    )? {
2424        Some(rows) => {
2425            for (id, _) in rows {
2426                let _ = store.delete(QUEUE_META_COLLECTION, id);
2427            }
2428        }
2429        None => remove_meta_rows(store, |row| {
2430            row_text(row, "kind").as_deref() == Some("queue_pending")
2431                && row_text(row, "queue").as_deref() == Some(queue)
2432                && row_text(row, "group").as_deref() == Some(group)
2433                && row_u64(row, "message_id") == Some(message_id.raw())
2434        }),
2435    }
2436
2437    let mut fields = HashMap::new();
2438    fields.insert("kind".to_string(), Value::text("queue_pending".to_string()));
2439    fields.insert("queue".to_string(), Value::text(queue.to_string()));
2440    fields.insert("group".to_string(), Value::text(group.to_string()));
2441    fields.insert(
2442        "message_id".to_string(),
2443        Value::UnsignedInteger(message_id.raw()),
2444    );
2445    fields.insert("consumer".to_string(), Value::text(consumer.to_string()));
2446    fields.insert(
2447        "delivered_at_ns".to_string(),
2448        Value::UnsignedInteger(delivered_at_ns),
2449    );
2450    fields.insert(
2451        "delivery_count".to_string(),
2452        Value::UnsignedInteger(u64::from(delivery_count)),
2453    );
2454    insert_meta_row(store, fields).map(|_| ())
2455}
2456
2457pub(super) fn require_pending_entry(
2458    store: &UnifiedStore,
2459    queue: &str,
2460    group: &str,
2461    message_id: EntityId,
2462) -> RedDBResult<QueuePendingEntry> {
2463    load_pending_entries(store, queue, Some(group), Some(message_id))?
2464        .into_iter()
2465        .next()
2466        .ok_or_else(|| {
2467            RedDBError::NotFound(format!(
2468                "message '{}' is not pending in group '{}' on queue '{}'",
2469                message_id.raw(),
2470                group,
2471                queue
2472            ))
2473        })
2474}
2475
2476pub(super) fn load_ack_entries(
2477    store: &UnifiedStore,
2478    queue: &str,
2479    group: Option<&str>,
2480    message_id: Option<EntityId>,
2481) -> RedDBResult<Vec<QueueAckEntry>> {
2482    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2483        return Ok(Vec::new());
2484    };
2485    let rows = match super::queue_storage_index::subject_rows_store(
2486        store,
2487        "queue_ack",
2488        queue,
2489        group,
2490        message_id.map(|id| id.raw()),
2491    )? {
2492        Some(rows) => rows,
2493        None => manager
2494            .query_all(|entity| {
2495                entity.data.as_row().is_some_and(|row| {
2496                    row_text(row, "kind").as_deref() == Some("queue_ack")
2497                        && row_text(row, "queue").as_deref() == Some(queue)
2498                        && group
2499                            .map(|group_name| row_text(row, "group").as_deref() == Some(group_name))
2500                            .unwrap_or(true)
2501                        && message_id
2502                            .map(|candidate| row_u64(row, "message_id") == Some(candidate.raw()))
2503                            .unwrap_or(true)
2504                })
2505            })
2506            .into_iter()
2507            .filter_map(|entity| match entity.data {
2508                EntityData::Row(row) => Some((entity.id, row)),
2509                _ => None,
2510            })
2511            .collect(),
2512    };
2513    Ok(rows
2514        .into_iter()
2515        .filter_map(|(entity_id, row)| {
2516            Some(QueueAckEntry {
2517                entity_id,
2518                group: row_text(&row, "group")?,
2519                message_id: EntityId::new(row_u64(&row, "message_id")?),
2520            })
2521        })
2522        .collect())
2523}
2524
2525pub(super) fn save_queue_ack(
2526    store: &UnifiedStore,
2527    queue: &str,
2528    group: &str,
2529    message_id: EntityId,
2530) -> RedDBResult<()> {
2531    let existing = load_ack_entries(store, queue, Some(group), Some(message_id))?;
2532    if !existing.is_empty() {
2533        return Ok(());
2534    }
2535
2536    let mut fields = HashMap::new();
2537    fields.insert("kind".to_string(), Value::text("queue_ack".to_string()));
2538    fields.insert("queue".to_string(), Value::text(queue.to_string()));
2539    fields.insert("group".to_string(), Value::text(group.to_string()));
2540    fields.insert(
2541        "message_id".to_string(),
2542        Value::UnsignedInteger(message_id.raw()),
2543    );
2544    fields.insert("acked_at_ns".to_string(), Value::UnsignedInteger(now_ns()));
2545    insert_meta_row(store, fields).map(|_| ())
2546}
2547
2548pub(super) fn queue_message_completed_for_all_groups(
2549    store: &UnifiedStore,
2550    queue: &str,
2551    message_id: EntityId,
2552) -> RedDBResult<bool> {
2553    let groups = load_queue_groups(store, queue)?;
2554    let pending = load_pending_entries(store, queue, None, Some(message_id))?;
2555    if !pending.is_empty() {
2556        return Ok(false);
2557    }
2558    if groups.is_empty() {
2559        return Ok(true);
2560    }
2561
2562    let acked_groups = load_ack_entries(store, queue, None, Some(message_id))?
2563        .into_iter()
2564        .map(|entry| entry.group)
2565        .collect::<HashSet<_>>();
2566    Ok(groups
2567        .into_iter()
2568        .all(|group| acked_groups.contains(&group.group)))
2569}
2570
2571fn load_queue_message_views(
2572    store: &UnifiedStore,
2573    queue: &str,
2574) -> RedDBResult<Vec<QueueMessageView>> {
2575    load_queue_message_views_with_runtime(None, store, queue)
2576}
2577
2578/// Count visible messages without cloning payloads or loading ordering metadata.
2579fn queue_capacity_reached_by_scan(
2580    runtime: &RedDBRuntime,
2581    store: &UnifiedStore,
2582    queue: &str,
2583    limit: usize,
2584) -> RedDBResult<bool> {
2585    let manager = store
2586        .get_collection(queue)
2587        .ok_or_else(|| RedDBError::NotFound(format!("queue '{queue}' not found")))?;
2588    let snapshot = super::impl_core::capture_current_snapshot();
2589    let policy = super::impl_core::rls_policy_filter_for_kind(
2590        runtime,
2591        queue,
2592        reddb_rql::ast::PolicyAction::Select,
2593        reddb_rql::ast::PolicyTargetKind::Messages,
2594    );
2595    if runtime.is_rls_enabled(queue) && policy.is_none() {
2596        return Ok(limit == 0);
2597    }
2598    let mut count = 0usize;
2599    manager.try_scan_for_each(
2600        snapshot.as_ref(),
2601        manager.backing_read_bytes_max().unwrap_or(usize::MAX),
2602        |entity| {
2603            if !matches!(entity.kind, EntityKind::QueueMessage { .. })
2604                || !matches!(entity.data, EntityData::QueueMessage(_))
2605            {
2606                return true;
2607            }
2608            if policy.as_ref().is_none_or(|filter| {
2609                super::query_exec::evaluate_entity_filter_with_db(
2610                    Some(&runtime.inner.db),
2611                    entity,
2612                    filter,
2613                    queue,
2614                    queue,
2615                )
2616            }) {
2617                count += 1;
2618            }
2619            count < limit
2620        },
2621    )?;
2622    Ok(count >= limit)
2623}
2624
2625/// Kind-aware queue scan (Phase 2.5.5 RLS universal). When the
2626/// caller has a `RedDBRuntime` reference, the gate also applies
2627/// any `CREATE POLICY ... ON MESSAGES OF <queue>` predicate. In
2628/// autocommit / embedded paths that only have the raw store (e.g.
2629/// purge loops) we skip RLS because there's no session identity
2630/// to match against.
2631pub(super) fn load_queue_message_views_with_runtime(
2632    runtime: Option<&RedDBRuntime>,
2633    store: &UnifiedStore,
2634    queue: &str,
2635) -> RedDBResult<Vec<QueueMessageView>> {
2636    let manager = store
2637        .get_collection(queue)
2638        .ok_or_else(|| RedDBError::NotFound(format!("queue '{}' not found", queue)))?;
2639    // Phase 1.2 MVCC universal: capture before parallel scan. Messages
2640    // inserted by another connection's open txn stay invisible to
2641    // consumers until that txn commits (prevents phantom POPs).
2642    let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
2643    let rls_filter = runtime.and_then(|rt| {
2644        crate::runtime::impl_core::rls_policy_filter_for_kind(
2645            rt,
2646            queue,
2647            reddb_rql::ast::PolicyAction::Select,
2648            reddb_rql::ast::PolicyTargetKind::Messages,
2649        )
2650    });
2651    let rls_enabled_but_denied = runtime.map(|rt| rt.is_rls_enabled(queue)).unwrap_or(false)
2652        && rls_filter.is_none()
2653        && runtime.is_some();
2654    if rls_enabled_but_denied {
2655        // RLS on + no Messages policy for this role = deny-default.
2656        return Ok(Vec::new());
2657    }
2658    let mut views = Vec::new();
2659    manager.try_scan_for_each(
2660        snap_ctx.as_ref(),
2661        manager.backing_read_bytes_max().unwrap_or(usize::MAX),
2662        |entity| {
2663            if !matches!(entity.kind, EntityKind::QueueMessage { .. }) {
2664                return true;
2665            }
2666            if let (Some(filter), Some(rt)) = (rls_filter.as_ref(), runtime) {
2667                if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
2668                    Some(&rt.inner.db),
2669                    entity,
2670                    filter,
2671                    queue,
2672                    queue,
2673                ) {
2674                    return true;
2675                }
2676            }
2677            if let Some(mut view) = queue_message_view_from_entity(entity.clone()) {
2678                view.available_at_ns = read_message_available_at_ns(store, queue, view.id);
2679                view.ordering_key = read_message_ordering_key(store, queue, view.id);
2680                views.push(view);
2681            }
2682            true
2683        },
2684    )?;
2685    Ok(views)
2686}
2687
2688fn queue_message_view_from_entity(entity: UnifiedEntity) -> Option<QueueMessageView> {
2689    let (position, _) = match &entity.kind {
2690        EntityKind::QueueMessage { position, queue } => (*position, queue),
2691        _ => return None,
2692    };
2693    let data = match entity.data {
2694        EntityData::QueueMessage(data) => data,
2695        _ => return None,
2696    };
2697    Some(QueueMessageView {
2698        id: entity.id,
2699        position,
2700        priority: data.priority.unwrap_or(0),
2701        payload: data.payload,
2702        attempts: data.attempts,
2703        max_attempts: data.max_attempts,
2704        enqueued_at_ns: data.enqueued_at_ns,
2705        available_at_ns: None,
2706        ordering_key: None,
2707    })
2708}
2709
2710/// Insert a moved payload onto `queue` using only the payload value —
2711/// priority / attempts / TTL fall back to the destination queue's
2712/// catalog config (mirrors a fresh enqueue rather than carrying source
2713/// metadata over). Used by `PrimaryQueueStore::move_to_queue`, the
2714/// `QueueLifecycle::move_between_queues` adapter that owns only
2715/// `(message_id, payload)` after `pop_messages` retires the source row.
2716pub(super) fn insert_moved_queue_message_payload(
2717    store: &UnifiedStore,
2718    queue: &str,
2719    payload: &Value,
2720) -> RedDBResult<EntityId> {
2721    let config = load_queue_config(store, queue);
2722    let position = next_queue_position(store, queue, QueueSide::Right)?;
2723    let enqueued_at_ns = std::time::SystemTime::now()
2724        .duration_since(std::time::UNIX_EPOCH)
2725        .map(|d| d.as_nanos() as u64)
2726        .unwrap_or(0);
2727    let entity = UnifiedEntity::new(
2728        EntityId::new(0),
2729        EntityKind::QueueMessage {
2730            queue: queue.to_string(),
2731            position,
2732        },
2733        EntityData::QueueMessage(QueueMessageData {
2734            payload: payload.clone(),
2735            priority: None,
2736            enqueued_at_ns,
2737            attempts: 0,
2738            max_attempts: config.max_attempts,
2739            acked: false,
2740        }),
2741    );
2742    let id = store
2743        .insert_auto(queue, entity)
2744        .map_err(|err| RedDBError::Internal(err.to_string()))?;
2745    if let Some(ttl_ms) = config.ttl_ms {
2746        store
2747            .set_metadata(queue, id, queue_message_ttl_metadata(ttl_ms))
2748            .map_err(|err| RedDBError::Internal(err.to_string()))?;
2749    }
2750    Ok(id)
2751}
2752
2753fn insert_moved_queue_message(
2754    store: &UnifiedStore,
2755    queue: &str,
2756    config: &QueueRuntimeConfig,
2757    message: &QueueMessageView,
2758) -> RedDBResult<EntityId> {
2759    let position = next_queue_position(store, queue, QueueSide::Right)?;
2760    let entity = UnifiedEntity::new(
2761        EntityId::new(0),
2762        EntityKind::QueueMessage {
2763            queue: queue.to_string(),
2764            position,
2765        },
2766        EntityData::QueueMessage(QueueMessageData {
2767            payload: message.payload.clone(),
2768            priority: if config.priority {
2769                Some(message.priority)
2770            } else {
2771                None
2772            },
2773            enqueued_at_ns: message.enqueued_at_ns,
2774            attempts: 0,
2775            max_attempts: config.max_attempts,
2776            acked: false,
2777        }),
2778    );
2779    let id = store
2780        .insert_auto(queue, entity)
2781        .map_err(|err| RedDBError::Internal(err.to_string()))?;
2782    if config.ttl_ms.is_some() || message.ordering_key.is_some() {
2783        store
2784            .set_metadata(
2785                queue,
2786                id,
2787                queue_message_metadata(config.ttl_ms, None, message.ordering_key.as_deref()),
2788            )
2789            .map_err(|err| RedDBError::Internal(err.to_string()))?;
2790    }
2791    Ok(id)
2792}
2793
2794fn queue_projection_default_columns() -> Vec<String> {
2795    [
2796        "id",
2797        "payload",
2798        "priority",
2799        "attempts",
2800        "last_error",
2801        "enqueued_at",
2802        "available_at",
2803        "key",
2804        "dlq",
2805        "tenant",
2806    ]
2807    .into_iter()
2808    .map(str::to_string)
2809    .collect()
2810}
2811
2812fn queue_projection_record(
2813    columns: &[String],
2814    message: &QueueMessageView,
2815    dlq: bool,
2816) -> RedDBResult<UnifiedRecord> {
2817    let mut record = UnifiedRecord::new();
2818    for column in columns {
2819        let value = queue_projection_value(message, dlq, column).ok_or_else(|| {
2820            RedDBError::Query(format!("unknown queue projection column '{}'", column))
2821        })?;
2822        record.set(column, value);
2823    }
2824    Ok(record)
2825}
2826
2827fn queue_projection_value(message: &QueueMessageView, dlq: bool, column: &str) -> Option<Value> {
2828    match column {
2829        "id" => Some(Value::text(message_id_string(message.id))),
2830        "payload" => Some(message.payload.clone()),
2831        "priority" => Some(Value::Integer(i64::from(message.priority))),
2832        "attempts" => Some(Value::UnsignedInteger(u64::from(message.attempts))),
2833        "last_error" => Some(Value::Null),
2834        "enqueued_at" => Some(Value::UnsignedInteger(message.enqueued_at_ns)),
2835        "available_at" => Some(Value::UnsignedInteger(
2836            message.available_at_ns.unwrap_or(message.enqueued_at_ns),
2837        )),
2838        "key" => Some(
2839            message
2840                .ordering_key
2841                .as_ref()
2842                .map(|key| Value::text(key.clone()))
2843                .unwrap_or(Value::Null),
2844        ),
2845        "dlq" => Some(Value::Boolean(dlq)),
2846        "tenant" => queue_message_tenant(&message.payload).or(Some(Value::Null)),
2847        _ => None,
2848    }
2849}
2850
2851fn queue_message_tenant(payload: &Value) -> Option<Value> {
2852    let Value::Json(bytes) = payload else {
2853        return None;
2854    };
2855    let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
2856    json.get("tenant")
2857        .and_then(crate::json::Value::as_str)
2858        .map(|tenant| Value::text(tenant.to_string()))
2859}
2860
2861fn queue_is_dead_letter_target(store: &UnifiedStore, queue: &str) -> bool {
2862    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2863        return false;
2864    };
2865    !manager
2866        .query_all(|entity| {
2867            entity.data.as_row().is_some_and(|row| {
2868                row_text(row, "kind").as_deref() == Some("queue_config")
2869                    && row_text(row, "dlq").as_deref() == Some(queue)
2870            })
2871        })
2872        .is_empty()
2873}
2874
2875fn queue_message_matches_filter(message: &QueueMessageView, dlq: bool, filter: &Filter) -> bool {
2876    match filter {
2877        Filter::Compare { field, op, value } => queue_filter_field_value(message, dlq, field)
2878            .is_some_and(|candidate| queue_compare_values(&candidate, value, *op)),
2879        Filter::CompareFields { left, op, right } => {
2880            match (
2881                queue_filter_field_value(message, dlq, left),
2882                queue_filter_field_value(message, dlq, right),
2883            ) {
2884                (Some(left), Some(right)) => queue_compare_values(&left, &right, *op),
2885                _ => false,
2886            }
2887        }
2888        Filter::And(left, right) => {
2889            queue_message_matches_filter(message, dlq, left)
2890                && queue_message_matches_filter(message, dlq, right)
2891        }
2892        Filter::Or(left, right) => {
2893            queue_message_matches_filter(message, dlq, left)
2894                || queue_message_matches_filter(message, dlq, right)
2895        }
2896        Filter::Not(inner) => !queue_message_matches_filter(message, dlq, inner),
2897        Filter::IsNull(field) => queue_filter_field_value(message, dlq, field)
2898            .is_none_or(|value| matches!(value, Value::Null)),
2899        Filter::IsNotNull(field) => queue_filter_field_value(message, dlq, field)
2900            .is_some_and(|value| !matches!(value, Value::Null)),
2901        Filter::In { field, values } => {
2902            queue_filter_field_value(message, dlq, field).is_some_and(|candidate| {
2903                values
2904                    .iter()
2905                    .any(|value| queue_values_equal(&candidate, value))
2906            })
2907        }
2908        Filter::Between { field, low, high } => queue_filter_field_value(message, dlq, field)
2909            .is_some_and(|candidate| {
2910                queue_compare_values(&candidate, low, CompareOp::Ge)
2911                    && queue_compare_values(&candidate, high, CompareOp::Le)
2912            }),
2913        Filter::Like { field, pattern } => queue_filter_text(message, dlq, field)
2914            .is_some_and(|value| queue_like_matches(&value, pattern)),
2915        Filter::StartsWith { field, prefix } => {
2916            queue_filter_text(message, dlq, field).is_some_and(|value| value.starts_with(prefix))
2917        }
2918        Filter::EndsWith { field, suffix } => {
2919            queue_filter_text(message, dlq, field).is_some_and(|value| value.ends_with(suffix))
2920        }
2921        Filter::Contains { field, substring } => {
2922            queue_filter_text(message, dlq, field).is_some_and(|value| value.contains(substring))
2923        }
2924        Filter::CompareExpr { .. } => false,
2925    }
2926}
2927
2928fn queue_filter_field_value(
2929    message: &QueueMessageView,
2930    dlq: bool,
2931    field: &FieldRef,
2932) -> Option<Value> {
2933    match field {
2934        FieldRef::TableColumn { table, column } if table.is_empty() => {
2935            queue_projection_value(message, dlq, column)
2936                .or_else(|| queue_payload_field_value(&message.payload, column))
2937        }
2938        FieldRef::TableColumn { column, .. } => queue_projection_value(message, dlq, column)
2939            .or_else(|| queue_payload_field_value(&message.payload, column)),
2940        _ => None,
2941    }
2942}
2943
2944fn queue_payload_field_value(payload: &Value, field: &str) -> Option<Value> {
2945    let Value::Json(bytes) = payload else {
2946        return None;
2947    };
2948    let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
2949    let value = json.get(field)?;
2950    json_value_to_schema_value(value)
2951}
2952
2953fn json_value_to_schema_value(value: &crate::json::Value) -> Option<Value> {
2954    if matches!(value, crate::json::Value::Null) {
2955        Some(Value::Null)
2956    } else if let Some(value) = value.as_bool() {
2957        Some(Value::Boolean(value))
2958    } else if let Some(value) = value.as_i64() {
2959        Some(Value::Integer(value))
2960    } else if let Some(value) = value.as_u64() {
2961        Some(Value::UnsignedInteger(value))
2962    } else if let Some(value) = value.as_f64() {
2963        Some(Value::Float(value))
2964    } else if let Some(value) = value.as_str() {
2965        Some(Value::text(value.to_string()))
2966    } else {
2967        Some(Value::Json(value.to_string_compact().into_bytes()))
2968    }
2969}
2970
2971fn queue_filter_text(message: &QueueMessageView, dlq: bool, field: &FieldRef) -> Option<String> {
2972    queue_filter_field_value(message, dlq, field).and_then(|value| match value {
2973        Value::Text(value) => Some(value.to_string()),
2974        Value::NodeRef(value) | Value::EdgeRef(value) | Value::TableRef(value) => Some(value),
2975        Value::Integer(value) => Some(value.to_string()),
2976        Value::UnsignedInteger(value) => Some(value.to_string()),
2977        Value::Float(value) => Some(value.to_string()),
2978        Value::Boolean(value) => Some(value.to_string()),
2979        _ => None,
2980    })
2981}
2982
2983fn queue_compare_values(left: &Value, right: &Value, op: CompareOp) -> bool {
2984    match op {
2985        CompareOp::Eq => queue_values_equal(left, right),
2986        CompareOp::Ne => !queue_values_equal(left, right),
2987        CompareOp::Lt => queue_partial_cmp(left, right).is_some_and(|ord| ord.is_lt()),
2988        CompareOp::Le => queue_partial_cmp(left, right).is_some_and(|ord| !ord.is_gt()),
2989        CompareOp::Gt => queue_partial_cmp(left, right).is_some_and(|ord| ord.is_gt()),
2990        CompareOp::Ge => queue_partial_cmp(left, right).is_some_and(|ord| !ord.is_lt()),
2991    }
2992}
2993
2994fn queue_values_equal(left: &Value, right: &Value) -> bool {
2995    if let (Some(left), Some(right)) = (queue_value_number(left), queue_value_number(right)) {
2996        return (left - right).abs() < f64::EPSILON;
2997    }
2998    match (left, right) {
2999        (Value::Text(left), Value::Text(right)) => left == right,
3000        (Value::Boolean(left), Value::Boolean(right)) => left == right,
3001        _ => left == right,
3002    }
3003}
3004
3005fn queue_partial_cmp(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
3006    if let (Some(left), Some(right)) = (queue_value_number(left), queue_value_number(right)) {
3007        return left.partial_cmp(&right);
3008    }
3009    match (left, right) {
3010        (Value::Text(left), Value::Text(right)) => Some(left.cmp(right)),
3011        _ => None,
3012    }
3013}
3014
3015fn queue_value_number(value: &Value) -> Option<f64> {
3016    match value {
3017        Value::Integer(value) => Some(*value as f64),
3018        Value::UnsignedInteger(value) => Some(*value as f64),
3019        Value::Float(value) => Some(*value),
3020        Value::Text(value) => value.parse().ok(),
3021        _ => None,
3022    }
3023}
3024
3025fn queue_like_matches(value: &str, pattern: &str) -> bool {
3026    if pattern == "%" {
3027        return true;
3028    }
3029    let starts_wild = pattern.starts_with('%');
3030    let ends_wild = pattern.ends_with('%');
3031    let needle = pattern.trim_matches('%');
3032    match (starts_wild, ends_wild) {
3033        (true, true) => value.contains(needle),
3034        (true, false) => value.ends_with(needle),
3035        (false, true) => value.starts_with(needle),
3036        (false, false) => value == needle,
3037    }
3038}
3039
3040pub(super) fn queue_message_view_by_id(
3041    store: &UnifiedStore,
3042    queue: &str,
3043    message_id: EntityId,
3044) -> RedDBResult<Option<QueueMessageView>> {
3045    let manager = queue_manager(store, queue)?;
3046    Ok(manager
3047        .get(message_id)
3048        .and_then(queue_message_view_from_entity)
3049        .map(|mut view| {
3050            view.available_at_ns = read_message_available_at_ns(store, queue, view.id);
3051            view
3052        }))
3053}
3054
3055pub(super) fn sort_queue_messages(
3056    messages: &mut [QueueMessageView],
3057    config: &QueueRuntimeConfig,
3058    side: QueueSide,
3059) {
3060    messages.sort_by(|left, right| {
3061        if config.priority {
3062            right
3063                .priority
3064                .cmp(&left.priority)
3065                .then_with(|| match side {
3066                    QueueSide::Left => left.position.cmp(&right.position),
3067                    QueueSide::Right => right.position.cmp(&left.position),
3068                })
3069                .then_with(|| left.id.raw().cmp(&right.id.raw()))
3070        } else {
3071            match side {
3072                QueueSide::Left => left.position.cmp(&right.position),
3073                QueueSide::Right => right.position.cmp(&left.position),
3074            }
3075            .then_with(|| left.id.raw().cmp(&right.id.raw()))
3076        }
3077    });
3078}
3079
3080pub(super) fn next_queue_position(
3081    store: &UnifiedStore,
3082    queue: &str,
3083    side: QueueSide,
3084) -> RedDBResult<u64> {
3085    if let Some(boundary) = super::queue_storage_index::position_boundary(
3086        store,
3087        queue,
3088        matches!(side, QueueSide::Right),
3089    )? {
3090        return Ok(
3091            boundary.map_or(QUEUE_POSITION_CENTER, |position| match side {
3092                QueueSide::Left => position.saturating_sub(1),
3093                QueueSide::Right => position.saturating_add(1),
3094            }),
3095        );
3096    }
3097    let manager = store
3098        .get_collection(queue)
3099        .ok_or_else(|| RedDBError::NotFound(format!("queue '{queue}' not found")))?;
3100    let snapshot = crate::runtime::impl_core::capture_current_snapshot();
3101    let mut boundary: Option<u64> = None;
3102    // Positioning needs one scalar, not copies of every queued payload. The
3103    // fallible merged visitor also refuses a partial/empty answer on cold I/O.
3104    manager.try_scan_for_each(snapshot.as_ref(), usize::MAX, |entity| {
3105        let EntityKind::QueueMessage { position, .. } = &entity.kind else {
3106            return true;
3107        };
3108        if !matches!(entity.data, EntityData::QueueMessage(_)) {
3109            return true;
3110        }
3111        boundary = Some(boundary.map_or(*position, |current| match side {
3112            QueueSide::Left => current.min(*position),
3113            QueueSide::Right => current.max(*position),
3114        }));
3115        true
3116    })?;
3117    Ok(
3118        boundary.map_or(QUEUE_POSITION_CENTER, |position| match side {
3119            QueueSide::Left => position.saturating_sub(1),
3120            QueueSide::Right => position.saturating_add(1),
3121        }),
3122    )
3123}
3124
3125pub(super) fn increment_queue_attempts(
3126    store: &UnifiedStore,
3127    queue: &str,
3128    message_id: EntityId,
3129) -> RedDBResult<u32> {
3130    let manager = queue_manager(store, queue)?;
3131    let mut entity = manager
3132        .get(message_id)
3133        .ok_or_else(|| RedDBError::NotFound(format!("message '{}' not found", message_id.raw())))?;
3134    match &mut entity.data {
3135        EntityData::QueueMessage(message) => {
3136            message.attempts = message.attempts.saturating_add(1);
3137            let attempts = message.attempts;
3138            manager
3139                .update(entity)
3140                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3141            Ok(attempts)
3142        }
3143        _ => Err(RedDBError::Query(format!(
3144            "entity '{}' is not a queue message",
3145            message_id.raw()
3146        ))),
3147    }
3148}
3149
3150pub(super) fn queue_message_attempts(
3151    store: &UnifiedStore,
3152    queue: &str,
3153    message_id: EntityId,
3154) -> RedDBResult<u32> {
3155    Ok(queue_message_data(store, queue, message_id)?.attempts)
3156}
3157
3158pub(super) fn queue_message_max_attempts(
3159    store: &UnifiedStore,
3160    queue: &str,
3161    message_id: EntityId,
3162) -> RedDBResult<u32> {
3163    Ok(queue_message_data(store, queue, message_id)?.max_attempts)
3164}
3165
3166pub(super) fn queue_message_payload(
3167    store: &UnifiedStore,
3168    queue: &str,
3169    message_id: EntityId,
3170) -> RedDBResult<Value> {
3171    Ok(queue_message_data(store, queue, message_id)?.payload)
3172}
3173
3174pub(super) fn queue_message_pending_any(
3175    store: &UnifiedStore,
3176    queue: &str,
3177    message_id: EntityId,
3178) -> RedDBResult<bool> {
3179    Ok(!load_pending_entries(store, queue, None, Some(message_id))?.is_empty())
3180}
3181
3182pub(super) fn queue_message_pending_for_group(
3183    store: &UnifiedStore,
3184    queue: &str,
3185    group: &str,
3186    message_id: EntityId,
3187) -> RedDBResult<bool> {
3188    Ok(!load_pending_entries(store, queue, Some(group), Some(message_id))?.is_empty())
3189}
3190
3191pub(super) fn queue_message_acked_for_group(
3192    store: &UnifiedStore,
3193    queue: &str,
3194    group: &str,
3195    message_id: EntityId,
3196) -> RedDBResult<bool> {
3197    Ok(!load_ack_entries(store, queue, Some(group), Some(message_id))?.is_empty())
3198}
3199
3200fn queue_manager(
3201    store: &UnifiedStore,
3202    queue: &str,
3203) -> RedDBResult<Arc<crate::storage::unified::SegmentManager>> {
3204    store
3205        .get_collection(queue)
3206        .ok_or_else(|| RedDBError::NotFound(format!("queue '{}' not found", queue)))
3207}
3208
3209pub(super) fn queue_message_data(
3210    store: &UnifiedStore,
3211    queue: &str,
3212    message_id: EntityId,
3213) -> RedDBResult<QueueMessageData> {
3214    let manager = queue_manager(store, queue)?;
3215    let entity = manager
3216        .get(message_id)
3217        .ok_or_else(|| RedDBError::NotFound(format!("message '{}' not found", message_id.raw())))?;
3218    match entity.data {
3219        EntityData::QueueMessage(message) => Ok(message),
3220        _ => Err(RedDBError::Query(format!(
3221            "entity '{}' is not a queue message",
3222            message_id.raw()
3223        ))),
3224    }
3225}
3226
3227fn insert_meta_row(store: &UnifiedStore, fields: HashMap<String, Value>) -> RedDBResult<EntityId> {
3228    let created = store.get_collection(QUEUE_META_COLLECTION).is_none();
3229    let _ = store.get_or_create_collection(QUEUE_META_COLLECTION);
3230    if created {
3231        super::queue_storage_index::prepare_meta_store(store)?;
3232    }
3233    let id = store
3234        .insert_auto(
3235            QUEUE_META_COLLECTION,
3236            UnifiedEntity::new(
3237                EntityId::new(0),
3238                EntityKind::TableRow {
3239                    table: Arc::from(QUEUE_META_COLLECTION),
3240                    row_id: 0,
3241                },
3242                EntityData::Row(RowData {
3243                    columns: Vec::new(),
3244                    named: Some(fields),
3245                    schema: None,
3246                }),
3247            ),
3248        )
3249        .map_err(|err| RedDBError::Internal(err.to_string()))?;
3250    Ok(id)
3251}
3252
3253pub(super) fn remove_meta_rows(store: &UnifiedStore, predicate: impl Fn(&RowData) -> bool + Sync) {
3254    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3255        return;
3256    };
3257    let rows = manager.query_all(|entity| entity.data.as_row().is_some_and(&predicate));
3258    for row in rows {
3259        let _ = store.delete(QUEUE_META_COLLECTION, row.id);
3260    }
3261}
3262
3263pub(super) fn reclaim_expired_queue_dedup(
3264    store: &UnifiedStore,
3265    queue: &str,
3266    now_ns: u64,
3267) -> RedDBResult<usize> {
3268    // Expiry is authoritative at lookup even while cleanup has a backlog.
3269    let rows = super::queue_storage_index::dedup_rows(store, queue, None, now_ns, 64)?;
3270    let count = rows.len();
3271    for (id, _) in rows {
3272        store.delete(QUEUE_META_COLLECTION, id)?;
3273    }
3274    Ok(count)
3275}
3276
3277pub(super) fn find_queue_dedup(
3278    store: &UnifiedStore,
3279    queue: &str,
3280    dedup_key: &str,
3281    now_ns: u64,
3282) -> RedDBResult<Option<EntityId>> {
3283    Ok(
3284        super::queue_storage_index::dedup_rows(store, queue, Some(dedup_key), now_ns, 1)?
3285            .into_iter()
3286            .next()
3287            .and_then(|(_, message)| message)
3288            .map(EntityId::new),
3289    )
3290}
3291
3292pub(super) fn record_queue_dedup(
3293    store: &UnifiedStore,
3294    queue: &str,
3295    dedup_key: &str,
3296    message_id: EntityId,
3297    expires_at_ns: u64,
3298) -> RedDBResult<EntityId> {
3299    let mut fields = HashMap::new();
3300    fields.insert(
3301        "kind".to_string(),
3302        Value::text(KIND_QUEUE_PUSH_DEDUP.to_string()),
3303    );
3304    fields.insert("queue".to_string(), Value::text(queue.to_string()));
3305    fields.insert("dedup_key".to_string(), Value::text(dedup_key.to_string()));
3306    fields.insert(
3307        "message_id".to_string(),
3308        Value::UnsignedInteger(message_id.raw()),
3309    );
3310    fields.insert(
3311        "expires_at_ns".to_string(),
3312        Value::UnsignedInteger(expires_at_ns),
3313    );
3314    insert_meta_row(store, fields)
3315}
3316
3317pub(super) fn delete_meta_entity(store: &UnifiedStore, entity_id: EntityId) {
3318    let _ = store.delete(QUEUE_META_COLLECTION, entity_id);
3319}
3320
3321fn queue_message_lock_key(queue: &str, message_id: EntityId) -> String {
3322    format!("{queue}:{}", message_id.raw())
3323}
3324
3325pub(super) fn queue_message_lock_handle(
3326    runtime: &RedDBRuntime,
3327    queue: &str,
3328    message_id: EntityId,
3329) -> Arc<parking_lot::Mutex<()>> {
3330    let key = queue_message_lock_key(queue, message_id);
3331    if let Some(lock) = runtime.inner.queue_message_locks.read().get(&key).cloned() {
3332        return lock;
3333    }
3334
3335    let mut locks = runtime.inner.queue_message_locks.write();
3336    locks
3337        .entry(key)
3338        .or_insert_with(|| Arc::new(parking_lot::Mutex::new(())))
3339        .clone()
3340}
3341
3342pub(super) fn forget_queue_message_lock(runtime: &RedDBRuntime, queue: &str, message_id: EntityId) {
3343    runtime
3344        .inner
3345        .queue_message_locks
3346        .write()
3347        .remove(&queue_message_lock_key(queue, message_id));
3348}
3349
3350fn parse_message_id(value: &str) -> RedDBResult<EntityId> {
3351    let raw = value.strip_prefix('e').unwrap_or(value);
3352    raw.parse::<u64>()
3353        .map(EntityId::new)
3354        .map_err(|_| RedDBError::Query(format!("invalid message id '{}'", value)))
3355}
3356
3357/// ADR 0026: resolve the ACK/NACK handle. When `delivery_id` is supplied,
3358/// it wins unconditionally — strict failure if the handle does not resolve
3359/// to a live pending delivery on `queue`. When only the legacy tuple is
3360/// supplied, emit a rate-limited deprecation log line and use the tuple.
3361/// At least one handle must be present.
3362pub(super) fn resolve_ack_nack_handle(
3363    store: &UnifiedStore,
3364    queue: &str,
3365    group_hint: &str,
3366    message_id_hint: &str,
3367    delivery_id: Option<&str>,
3368) -> RedDBResult<(String, EntityId)> {
3369    if let Some(did) = delivery_id {
3370        return resolve_delivery_id(store, queue, did);
3371    }
3372    if group_hint.is_empty() || message_id_hint.is_empty() {
3373        return Err(RedDBError::Query(
3374            "ACK/NACK requires either GROUP <group> '<message_id>' or WITH delivery_id = '<id>'"
3375                .to_string(),
3376        ));
3377    }
3378    log_tuple_deprecation(queue);
3379    let entity = parse_message_id(message_id_hint)?;
3380    Ok((group_hint.to_string(), entity))
3381}
3382
3383fn resolve_delivery_id(
3384    store: &UnifiedStore,
3385    queue: &str,
3386    delivery_id: &str,
3387) -> RedDBResult<(String, EntityId)> {
3388    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3389        return Err(RedDBError::Query(format!(
3390            "delivery_id '{}' does not resolve to a live pending delivery",
3391            delivery_id
3392        )));
3393    };
3394    let rows = match super::queue_storage_index::delivery_rows_store(store, delivery_id)? {
3395        Some(rows) => rows,
3396        None => manager
3397            .query_all(|entity| {
3398                entity.data.as_row().is_some_and(|row| {
3399                    row_text(row, "kind").as_deref() == Some("queue_pending_lc")
3400                        && row_text(row, "delivery_id").as_deref() == Some(delivery_id)
3401                })
3402            })
3403            .into_iter()
3404            .filter_map(|entity| match entity.data {
3405                EntityData::Row(row) => Some((entity.id, row)),
3406                _ => None,
3407            })
3408            .collect(),
3409    };
3410    for (_, row) in rows {
3411        if row_text(&row, "kind").as_deref() == Some("queue_pending_lc") {
3412            let row_queue = row_text(&row, "queue").unwrap_or_default();
3413            let row_group = row_text(&row, "group").unwrap_or_default();
3414            let row_message = row_u64(&row, "message_id").unwrap_or(0);
3415            if row_queue != queue {
3416                return Err(RedDBError::Query(format!(
3417                    "delivery_id '{}' belongs to queue '{}', not '{}'",
3418                    delivery_id, row_queue, queue
3419                )));
3420            }
3421            return Ok((row_group, EntityId::new(row_message)));
3422        }
3423    }
3424    Err(RedDBError::Query(format!(
3425        "delivery_id '{}' does not resolve to a live pending delivery",
3426        delivery_id
3427    )))
3428}
3429
3430/// Per-(connection, queue) rate-limited "tuple ACK is deprecated" log line.
3431/// One emission per minute matches ADR 0026's operational guidance.
3432fn log_tuple_deprecation(queue: &str) {
3433    use std::sync::atomic::Ordering;
3434    use std::sync::{Mutex, OnceLock};
3435    use std::time::Instant;
3436
3437    static LAST_EMIT: OnceLock<Mutex<HashMap<(u64, String), Instant>>> = OnceLock::new();
3438    const COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60);
3439
3440    let map = LAST_EMIT.get_or_init(|| Mutex::new(HashMap::new()));
3441    let key = (super::impl_core::current_connection_id(), queue.to_string());
3442    let now = Instant::now();
3443    let mut guard = match map.lock() {
3444        Ok(g) => g,
3445        Err(_) => return,
3446    };
3447    let should_emit =
3448        !matches!(guard.get(&key), Some(prev) if now.duration_since(*prev) < COOLDOWN);
3449    if should_emit {
3450        guard.insert(key.clone(), now);
3451        drop(guard);
3452        TUPLE_DEPRECATION_EMITS.fetch_add(1, Ordering::Relaxed);
3453        tracing::warn!(
3454            target: "reddb::queue_lifecycle",
3455            queue = queue,
3456            connection_id = key.0,
3457            "ACK/NACK by (queue, group, message_id) tuple is deprecated; \
3458             switch to the server-issued delivery_id (ADR 0026). \
3459             The tuple path will be removed one minor release after introduction.",
3460        );
3461    }
3462}
3463
3464/// Total count of tuple-deprecation log emissions since process start.
3465/// Intentionally process-wide and `pub` so the transport-bridge
3466/// integration tests can observe that the legacy tuple path emitted a
3467/// deprecation while the `delivery_id` path stayed silent, without
3468/// having to plumb a `tracing::Subscriber` through every test.
3469pub static TUPLE_DEPRECATION_EMITS: std::sync::atomic::AtomicU64 =
3470    std::sync::atomic::AtomicU64::new(0);
3471
3472fn message_id_string(message_id: EntityId) -> String {
3473    message_id.raw().to_string()
3474}
3475
3476/// Issue #917 — render a delivered queue message as the JSON object the
3477/// RedWire `QueueEventPush` frame carries. Mirrors the column shape the
3478/// SQL `QUEUE READ` projection emits (`message_id` / `payload` /
3479/// `consumer` / `delivery_count`) so the wire push and the pull path
3480/// stay client-compatible.
3481fn delivered_message_json(
3482    message: crate::runtime::queue_lifecycle::DeliveredMessage,
3483) -> crate::serde_json::Value {
3484    use crate::serde_json::{Map, Value as JsonValue};
3485    let mut obj = Map::new();
3486    obj.insert(
3487        "message_id".to_string(),
3488        JsonValue::String(message_id_string(EntityId::new(message.message_id))),
3489    );
3490    obj.insert("payload".to_string(), message.payload.to_json());
3491    obj.insert("consumer".to_string(), JsonValue::String(message.consumer));
3492    obj.insert(
3493        "delivery_count".to_string(),
3494        JsonValue::Number(message.delivery_count as f64),
3495    );
3496    JsonValue::Object(obj)
3497}
3498
3499/// Slice 10 of issue #527 — render-time scan of pending entries
3500/// per (queue, group) for `queue_pending_gauge` exposition. Walks
3501/// `red_queue_meta` live so the gauge cannot drift from the source
3502/// of truth.
3503pub(crate) fn pending_counts_by_group(
3504    store: &UnifiedStore,
3505) -> std::collections::BTreeMap<(String, String), u64> {
3506    let mut counts: std::collections::BTreeMap<(String, String), u64> =
3507        std::collections::BTreeMap::new();
3508    let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3509        return counts;
3510    };
3511    for entity in manager.query_all(|entity| {
3512        entity
3513            .data
3514            .as_row()
3515            .is_some_and(|row| row_text(row, "kind").as_deref() == Some("queue_pending"))
3516    }) {
3517        if let Some(row) = entity.data.as_row() {
3518            let queue = row_text(row, "queue");
3519            let group = row_text(row, "group");
3520            if let (Some(q), Some(g)) = (queue, group) {
3521                *counts.entry((q, g)).or_insert(0) += 1;
3522            }
3523        }
3524    }
3525    counts
3526}
3527
3528pub(super) fn row_text(row: &RowData, field: &str) -> Option<String> {
3529    match row.get_field(field)?.clone() {
3530        Value::Text(value) => Some(value.to_string()),
3531        Value::NodeRef(value) => Some(value),
3532        Value::EdgeRef(value) => Some(value),
3533        Value::TableRef(value) => Some(value),
3534        _ => None,
3535    }
3536}
3537
3538pub(super) fn row_u64(row: &RowData, field: &str) -> Option<u64> {
3539    match row.get_field(field)?.clone() {
3540        Value::UnsignedInteger(value) => Some(value),
3541        Value::Integer(value) if value >= 0 => Some(value as u64),
3542        Value::Float(value) if value >= 0.0 => Some(value as u64),
3543        Value::Text(value) => value.parse().ok(),
3544        _ => None,
3545    }
3546}
3547
3548fn row_bool(row: &RowData, field: &str) -> Option<bool> {
3549    match row.get_field(field)?.clone() {
3550        Value::Boolean(value) => Some(value),
3551        Value::Text(value) => match value.to_ascii_lowercase().as_str() {
3552            "true" => Some(true),
3553            "false" => Some(false),
3554            _ => None,
3555        },
3556        _ => None,
3557    }
3558}
3559
3560fn queue_collection_contract(
3561    name: &str,
3562    priority: bool,
3563    ttl_ms: Option<u64>,
3564) -> crate::physical::CollectionContract {
3565    let now = current_unix_ms();
3566    let mut context_index_fields = Vec::new();
3567    if priority {
3568        context_index_fields.push("priority".to_string());
3569    }
3570
3571    crate::physical::CollectionContract {
3572        name: name.to_string(),
3573        declared_model: crate::catalog::CollectionModel::Queue,
3574        schema_mode: crate::catalog::SchemaMode::Dynamic,
3575        origin: crate::physical::ContractOrigin::Explicit,
3576        version: 1,
3577        created_at_unix_ms: now,
3578        updated_at_unix_ms: now,
3579        default_ttl_ms: ttl_ms,
3580        vector_dimension: None,
3581        vector_metric: None,
3582        context_index_fields,
3583        declared_columns: Vec::new(),
3584        table_def: None,
3585        timestamps_enabled: false,
3586        context_index_enabled: false,
3587        metrics_raw_retention_ms: None,
3588        metrics_rollup_policies: Vec::new(),
3589        metrics_tenant_identity: None,
3590        metrics_namespace: None,
3591        // Queues manipulate messages via push/pop/ack — the row DML
3592        // paths never apply. Flag it as append_only so inadvertent
3593        // `UPDATE/DELETE FROM queue_name` statements fail loudly.
3594        append_only: true,
3595        subscriptions: Vec::new(),
3596        analytics_config: Vec::new(),
3597        session_key: None,
3598        session_gap_ms: None,
3599        retention_duration_ms: None,
3600        analytical_storage: None,
3601
3602        ai_policy: None,
3603    }
3604}
3605
3606fn current_unix_ms() -> u128 {
3607    std::time::SystemTime::now()
3608        .duration_since(std::time::UNIX_EPOCH)
3609        .unwrap_or_default()
3610        .as_millis()
3611}
3612
3613pub(super) fn now_ns() -> u64 {
3614    std::time::SystemTime::now()
3615        .duration_since(std::time::UNIX_EPOCH)
3616        .unwrap_or_default()
3617        .as_nanos() as u64
3618}
3619
3620pub(super) fn queue_message_ttl_metadata(ttl_ms: u64) -> Metadata {
3621    queue_message_metadata(Some(ttl_ms), None, None)
3622}
3623
3624/// Build the per-message metadata row attached to a queue message. Both
3625/// fields are optional — `_ttl_ms` carries the per-message TTL (existing
3626/// behaviour) and `_available_at_ns` carries the first-delivery instant
3627/// for delayed messages (issue #722). When both are present they share
3628/// the same row, since `UnifiedStore::set_metadata` replaces the entry.
3629pub(super) fn queue_message_metadata(
3630    ttl_ms: Option<u64>,
3631    available_at_ns: Option<u64>,
3632    ordering_key: Option<&str>,
3633) -> Metadata {
3634    let mut fields = HashMap::new();
3635    if let Some(ttl_ms) = ttl_ms {
3636        fields.insert(
3637            "_ttl_ms".to_string(),
3638            if ttl_ms <= i64::MAX as u64 {
3639                MetadataValue::Int(ttl_ms as i64)
3640            } else {
3641                MetadataValue::Timestamp(ttl_ms)
3642            },
3643        );
3644    }
3645    if let Some(at_ns) = available_at_ns {
3646        fields.insert(
3647            "_available_at_ns".to_string(),
3648            MetadataValue::Timestamp(at_ns),
3649        );
3650    }
3651    if let Some(key) = ordering_key {
3652        fields.insert(
3653            "_ordering_key".to_string(),
3654            MetadataValue::String(key.to_string()),
3655        );
3656    }
3657    Metadata::with_fields(fields)
3658}
3659
3660/// Smallest future `available_at_ns` among messages currently sitting
3661/// on `queue`. Used by `QUEUE READ … WAIT` (issue #722) to cap the
3662/// condvar park horizon: without this, a waiter on a quiet queue with
3663/// only delayed messages would never wake when one became due, since
3664/// the wait registry is only notified by producer commits. Returns
3665/// `None` when no future-dated message exists (the common case — the
3666/// caller falls back to the user-supplied wait budget).
3667pub(super) fn earliest_future_available_at(store: &UnifiedStore, queue: &str) -> Option<u64> {
3668    let now_ns = now_ns();
3669    let views = load_queue_message_views_with_runtime(None, store, queue).ok()?;
3670    views
3671        .iter()
3672        .filter_map(|v| v.available_at_ns)
3673        .filter(|at| *at > now_ns)
3674        .min()
3675}
3676
3677/// Update the `_available_at_ns` metadata for a queue message in place
3678/// without dropping any `_ttl_ms` already present (issue #723 — used by
3679/// NACK retry delay). `available_at_ns = None` clears the field. The
3680/// `fallback_ttl_ms` argument is consulted only when the existing
3681/// metadata row carries no `_ttl_ms` — keeps the per-message TTL in
3682/// sync with the queue default in the common case where no per-message
3683/// TTL was set explicitly. Tolerant of missing collections / entities:
3684/// returns `Ok(())` rather than failing the surrounding NACK if the
3685/// underlying message has gone away between resolution and metadata
3686/// update (a benign race; the next delivery cycle reflects truth).
3687pub(super) fn set_message_available_at_ns(
3688    store: &UnifiedStore,
3689    queue: &str,
3690    message_id: EntityId,
3691    available_at_ns: Option<u64>,
3692    fallback_ttl_ms: Option<u64>,
3693) -> RedDBResult<()> {
3694    let existing_ttl_ms = store
3695        .get_metadata(queue, message_id)
3696        .and_then(|md| match md.get("_ttl_ms")? {
3697            MetadataValue::Int(i) if *i >= 0 => Some(*i as u64),
3698            MetadataValue::Timestamp(t) => Some(*t),
3699            _ => None,
3700        })
3701        .or(fallback_ttl_ms);
3702    let existing_ordering_key = read_message_ordering_key(store, queue, message_id);
3703    let metadata = queue_message_metadata(
3704        existing_ttl_ms,
3705        available_at_ns,
3706        existing_ordering_key.as_deref(),
3707    );
3708    match store.set_metadata(queue, message_id, metadata) {
3709        Ok(()) => Ok(()),
3710        Err(crate::storage::StoreError::CollectionNotFound(_)) => Ok(()),
3711        Err(err) => Err(RedDBError::Internal(err.to_string())),
3712    }
3713}
3714
3715/// Read the `_ordering_key` metadata for a queue message. Returns `None`
3716/// for keyless messages or when the metadata row is absent.
3717pub(super) fn read_message_ordering_key(
3718    store: &UnifiedStore,
3719    queue: &str,
3720    message_id: EntityId,
3721) -> Option<String> {
3722    let md = store.get_metadata(queue, message_id)?;
3723    match md.get("_ordering_key")? {
3724        MetadataValue::String(value) => Some(value.clone()),
3725        _ => None,
3726    }
3727}
3728
3729/// Read the `_available_at_ns` metadata for a queue message. Returns
3730/// `None` for messages with no delay (the common case) or when the
3731/// metadata row is missing entirely.
3732pub(super) fn read_message_available_at_ns(
3733    store: &UnifiedStore,
3734    queue: &str,
3735    message_id: EntityId,
3736) -> Option<u64> {
3737    let md = store.get_metadata(queue, message_id)?;
3738    match md.get("_available_at_ns")? {
3739        MetadataValue::Timestamp(t) => Some(*t),
3740        MetadataValue::Int(i) if *i >= 0 => Some(*i as u64),
3741        _ => None,
3742    }
3743}
3744
3745/// Rough payload byte estimate for outbox watermark tracking.
3746fn estimate_payload_bytes(payload: &Value) -> u64 {
3747    match payload {
3748        Value::Json(v) => v.len() as u64,
3749        Value::Text(s) => s.len() as u64,
3750        _ => 64,
3751    }
3752}
3753
3754#[cfg(test)]
3755mod presence_integration_tests {
3756    use super::*;
3757    use crate::storage::queue::presence::{PresenceState, DEFAULT_PRESENCE_TTL_MS};
3758    use crate::{RedDBOptions, RedDBRuntime};
3759
3760    /// Issue #742 acceptance: `QUEUE READ` must register and refresh
3761    /// consumer presence. The snapshot exposes the same `(queue, group,
3762    /// consumer)` triple the read named, with `state = Active`.
3763    #[test]
3764    fn queue_read_emits_consumer_presence_heartbeat() {
3765        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3766        rt.execute_query("CREATE QUEUE tasks").unwrap();
3767        rt.execute_query("QUEUE GROUP CREATE tasks workers")
3768            .unwrap();
3769        rt.execute_query("QUEUE PUSH tasks {'job':'a'}").unwrap();
3770        rt.execute_query("QUEUE READ tasks GROUP workers CONSUMER w1")
3771            .unwrap();
3772
3773        let snap = rt.queue_consumer_presence_snapshot(DEFAULT_PRESENCE_TTL_MS);
3774        assert_eq!(snap.len(), 1, "exactly one heartbeat recorded");
3775        let row = &snap[0];
3776        assert_eq!(row.queue, "tasks");
3777        assert_eq!(row.group, "workers");
3778        assert_eq!(row.consumer, "w1");
3779        assert_eq!(row.state, PresenceState::Active);
3780
3781        let counts = rt.queue_active_consumer_counts(DEFAULT_PRESENCE_TTL_MS);
3782        assert_eq!(
3783            counts[&("tasks".to_string(), "workers".to_string())],
3784            1,
3785            "active count reflects the live consumer"
3786        );
3787    }
3788
3789    /// Issue #742 acceptance: a read that returns no messages must
3790    /// still heartbeat — aliveness is independent of pending
3791    /// deliveries.
3792    #[test]
3793    fn empty_queue_read_still_heartbeats() {
3794        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3795        rt.execute_query("CREATE QUEUE empty_q").unwrap();
3796        rt.execute_query("QUEUE GROUP CREATE empty_q workers")
3797            .unwrap();
3798        // No PUSH — the queue is empty.
3799        rt.execute_query("QUEUE READ empty_q GROUP workers CONSUMER w1")
3800            .unwrap();
3801
3802        let snap = rt.queue_consumer_presence_snapshot(DEFAULT_PRESENCE_TTL_MS);
3803        assert_eq!(
3804            snap.len(),
3805            1,
3806            "empty read still registers consumer presence"
3807        );
3808        assert_eq!(snap[0].state, PresenceState::Active);
3809        assert_eq!(
3810            snap[0].lease_count, 0,
3811            "no messages delivered → zero leases"
3812        );
3813    }
3814}