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