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