1use std::collections::{HashMap, HashSet};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Arc;
6
7use crate::runtime::audit_log::{AuditAuthSource, AuditEvent, AuditFieldEscaper, Outcome};
8use crate::runtime::impl_core::{
9 clear_current_auth_identity, clear_current_tenant, current_auth_identity,
10 current_connection_id, current_tenant, set_current_auth_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
26pub(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
52pub(crate) const QUEUE_READ_WAIT_CANCELLED: &str =
58 "QUEUE READ WAIT cancelled — server shutting down";
59
60pub(crate) const QUEUE_MAX_WAIT_MS_CONFIG_KEY: &str = "red.config.queue.max_wait_ms";
64
65pub(crate) const QUEUE_MAX_WAIT_MS_DEFAULT: u64 = 60_000;
68
69#[derive(Debug)]
80pub(crate) enum RedwireWaitOutcome {
81 Delivered(Vec<crate::serde_json::Value>),
82 TimedOut,
83 Cancelled,
84}
85
86pub(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
121fn ast_side_to_lc(side: reddb_rql::ast::QueueSide) -> LcQueueSide {
125 use reddb_rql::ast::QueueSide as Ast;
126 match side {
127 Ast::Left => LcQueueSide::Left,
128 Ast::Right => LcQueueSide::Right,
129 }
130}
131
132fn 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
146pub static EVENTS_DRAIN_RETRIES_TOTAL: AtomicU64 = AtomicU64::new(0);
153
154pub static EVENTS_DLQ_TOTAL: AtomicU64 = AtomicU64::new(0);
156
157pub static EVENTS_ENQUEUED_TOTAL: AtomicU64 = AtomicU64::new(0);
159
160const OUTBOX_WARN_BYTES: u64 = 1 << 30;
162
163const OUTBOX_MAX_BYTES: u64 = 10 * (1 << 30);
165
166static OUTBOX_APPROX_BYTES: AtomicU64 = AtomicU64::new(0);
168
169pub(super) const QUEUE_META_COLLECTION: &str = "red_queue_meta";
170pub(super) const KIND_QUEUE_PUSH_DEDUP: &str = "queue_push_dedup";
171const QUEUE_POSITION_CENTER: u64 = u64::MAX / 2;
172const WORK_DEFAULT_GROUP: &str = "_work_default";
173const FANOUT_GROUP_PREFIX: &str = "_fanout_";
174
175#[derive(Debug, Clone)]
176pub(super) struct QueueRuntimeConfig {
177 pub(super) mode: QueueMode,
178 pub(super) priority: bool,
179 pub(super) max_size: Option<usize>,
180 pub(super) ttl_ms: Option<u64>,
181 pub(super) dlq: Option<String>,
182 pub(super) max_attempts: u32,
183 pub(super) lock_deadline_ms: u64,
184 pub(super) in_flight_cap_per_group: u32,
185 pub(super) retry_delay_ms: Option<u64>,
190 pub(super) dedup_window_ms: Option<u64>,
193}
194
195#[derive(Debug, Clone)]
196struct QueueGroupEntry {
197 entity_id: EntityId,
198 group: String,
199}
200
201#[derive(Debug, Clone)]
202pub(super) struct QueuePendingEntry {
203 pub(super) entity_id: EntityId,
204 group: String,
205 pub(super) message_id: EntityId,
206 consumer: String,
207 pub(super) delivered_at_ns: u64,
208 pub(super) delivery_count: u32,
209}
210
211#[derive(Debug, Clone)]
212pub(super) struct QueueAckEntry {
213 entity_id: EntityId,
214 group: String,
215 pub(super) message_id: EntityId,
216}
217
218#[derive(Debug, Clone)]
219pub(super) struct QueueMessageView {
220 pub(super) id: EntityId,
221 position: u64,
222 priority: i32,
223 pub(super) payload: Value,
224 attempts: u32,
225 pub(super) max_attempts: u32,
226 enqueued_at_ns: u64,
227 pub(super) available_at_ns: Option<u64>,
231 pub(super) ordering_key: Option<String>,
233}
234
235impl QueueMessageView {
236 pub(super) fn is_available_now(&self) -> bool {
241 match self.available_at_ns {
242 Some(at) => at <= now_ns(),
243 None => true,
244 }
245 }
246}
247
248impl RedDBRuntime {
249 pub(super) fn group_read_with_optional_wait(
258 &self,
259 queue: &str,
260 group: &str,
261 consumer: &str,
262 count: usize,
263 wait_ms: Option<u64>,
264 ) -> RedDBResult<Vec<crate::runtime::queue_lifecycle::DeliveredMessage>> {
265 let do_read =
266 |runtime: &RedDBRuntime| -> RedDBResult<Vec<crate::runtime::queue_lifecycle::DeliveredMessage>> {
267 let read_lock = runtime
273 .inner
274 .rmw_locks
275 .lock_for(queue, "__queue_group_read__");
276 let _read_guard = read_lock.lock();
277 let _snapshot_guard = wait_ms
281 .and_then(|_| crate::runtime::impl_core::capture_current_snapshot())
282 .map(|mut context| {
283 context.snapshot = context.manager.fresh_read_snapshot();
284 crate::runtime::execution_context::CurrentSnapshotGuard::install(context)
285 });
286 let (lifecycle, _ps, txn) = runtime_lifecycle(runtime, queue);
287 lifecycle
288 .group_read(&txn, queue, group, consumer, count)
289 .map_err(map_qse)
290 };
291
292 let delivered = do_read(self)?;
293 let Some(wait_ms) = wait_ms else {
294 return Ok(delivered);
295 };
296 if !delivered.is_empty() {
297 return Ok(delivered);
298 }
299 let registry = self.queue_wait_registry();
309 let scope = queue_wait_scope();
310 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(wait_ms);
311 let telemetry = self.queue_telemetry();
312 telemetry.record_wait_started(&scope, queue);
313 let wait_start = std::time::Instant::now();
314 let observe = |outcome: crate::runtime::queue_telemetry::WaitOutcomeLabel| {
315 let elapsed_ms = wait_start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
316 telemetry.record_wait_outcome(&scope, queue, outcome, elapsed_ms);
317 };
318 loop {
319 let snapshot = registry.snapshot(&scope, queue);
323 let delivered = do_read(self)?;
324 if !delivered.is_empty() {
325 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Woken);
326 return Ok(delivered);
327 }
328 if registry.is_cancelled() {
329 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Cancelled);
330 return Err(RedDBError::Query(QUEUE_READ_WAIT_CANCELLED.to_string()));
331 }
332 if std::time::Instant::now() >= deadline {
333 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Timeout);
334 return Ok(Vec::new());
335 }
336 let park_deadline = match earliest_future_available_at(&self.inner.db.store(), queue) {
346 Some(at_ns) => {
347 let now_ns = now_ns();
348 if at_ns <= now_ns {
349 deadline.min(std::time::Instant::now())
351 } else {
352 let wait_ns = at_ns - now_ns;
353 let due_instant =
354 std::time::Instant::now() + std::time::Duration::from_nanos(wait_ns);
355 deadline.min(due_instant)
356 }
357 }
358 None => deadline,
359 };
360 match registry.wait_until(&snapshot, park_deadline) {
361 crate::runtime::queue_wait_registry::WaitOutcome::Woken => continue,
362 crate::runtime::queue_wait_registry::WaitOutcome::Timeout => {
363 if std::time::Instant::now() >= deadline {
367 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Timeout);
368 return Ok(Vec::new());
369 }
370 continue;
371 }
372 crate::runtime::queue_wait_registry::WaitOutcome::Cancelled => {
373 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Cancelled);
374 return Err(RedDBError::Query(QUEUE_READ_WAIT_CANCELLED.to_string()));
375 }
376 }
377 }
378 }
379
380 pub(crate) async fn redwire_queue_wait_json(
397 &self,
398 queue: &str,
399 group: Option<&str>,
400 consumer: &str,
401 count: usize,
402 wait_ms: u64,
403 auth_identity: Option<(String, crate::auth::Role)>,
404 tenant: Option<String>,
405 ) -> RedDBResult<RedwireWaitOutcome> {
406 let group_owned: RedDBResult<String> =
407 with_redwire_wait_context(&auth_identity, &tenant, || {
408 let expr = reddb_rql::ast::QueryExpr::QueueCommand(
409 reddb_rql::ast::QueueCommand::GroupRead {
410 queue: queue.to_string(),
411 group: group.map(str::to_string),
412 consumer: consumer.to_string(),
413 count,
414 wait_ms: Some(wait_ms),
415 },
416 );
417 self.check_query_privilege(&expr)
418 .map_err(RedDBError::Query)?;
419 let store = self.inner.db.store();
420 ensure_queue_exists(store.as_ref(), queue)?;
421 let config = load_queue_config(store.as_ref(), queue);
422 resolve_read_group(store.as_ref(), queue, group, consumer, &config)
423 });
424 let group_owned = group_owned?;
425 let group_ref = group_owned.as_str();
426
427 let do_read =
428 |runtime: &RedDBRuntime| -> RedDBResult<Vec<crate::runtime::queue_lifecycle::DeliveredMessage>> {
429 with_redwire_wait_context(&auth_identity, &tenant, || {
430 let (lifecycle, _ps, txn) = runtime_lifecycle(runtime, queue);
431 lifecycle
432 .group_read(&txn, queue, group_ref, consumer, count)
433 .map_err(map_qse)
434 })
435 };
436
437 let render = |delivered: Vec<crate::runtime::queue_lifecycle::DeliveredMessage>| {
438 RedwireWaitOutcome::Delivered(
439 delivered.into_iter().map(delivered_message_json).collect(),
440 )
441 };
442
443 let delivered = do_read(self)?;
445 if !delivered.is_empty() {
446 return Ok(render(delivered));
447 }
448
449 let registry = self.queue_wait_registry();
450 let scope = with_redwire_wait_context(&auth_identity, &tenant, queue_wait_scope);
451 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(wait_ms);
452 let telemetry = self.queue_telemetry();
453 telemetry.record_wait_started(&scope, queue);
454 let wait_start = std::time::Instant::now();
455 tracing::debug!(
456 target: "reddb::redwire::queue_wait",
457 queue,
458 group = group_ref,
459 consumer,
460 count,
461 wait_ms,
462 scope = scope.as_str(),
463 "redwire queue wait parked"
464 );
465 let observe = |outcome: crate::runtime::queue_telemetry::WaitOutcomeLabel| {
466 let elapsed_ms = wait_start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
467 telemetry.record_wait_outcome(&scope, queue, outcome, elapsed_ms);
468 tracing::debug!(
469 target: "reddb::redwire::queue_wait",
470 queue,
471 group = group_ref,
472 consumer,
473 count,
474 wait_ms,
475 scope = scope.as_str(),
476 outcome = outcome.as_str(),
477 duration_ms = elapsed_ms,
478 "redwire queue wait resolved"
479 );
480 };
481 loop {
482 let waiter = registry.async_waiter(&scope, queue);
486 let delivered = do_read(self)?;
487 if !delivered.is_empty() {
488 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Woken);
489 return Ok(render(delivered));
490 }
491 if registry.is_cancelled() {
492 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Cancelled);
493 return Ok(RedwireWaitOutcome::Cancelled);
494 }
495 if std::time::Instant::now() >= deadline {
496 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Timeout);
497 return Ok(RedwireWaitOutcome::TimedOut);
498 }
499 let park_deadline = match earliest_future_available_at(&self.inner.db.store(), queue) {
500 Some(at_ns) => {
501 let now_ns = now_ns();
502 if at_ns <= now_ns {
503 deadline.min(std::time::Instant::now())
504 } else {
505 let wait_ns = at_ns - now_ns;
506 let due_instant =
507 std::time::Instant::now() + std::time::Duration::from_nanos(wait_ns);
508 deadline.min(due_instant)
509 }
510 }
511 None => deadline,
512 };
513 match registry.wait_until_async(&waiter, park_deadline).await {
518 crate::runtime::queue_wait_registry::WaitOutcome::Woken => continue,
519 crate::runtime::queue_wait_registry::WaitOutcome::Timeout => {
520 if std::time::Instant::now() >= deadline {
521 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Timeout);
522 return Ok(RedwireWaitOutcome::TimedOut);
523 }
524 continue;
525 }
526 crate::runtime::queue_wait_registry::WaitOutcome::Cancelled => {
527 observe(crate::runtime::queue_telemetry::WaitOutcomeLabel::Cancelled);
528 return Ok(RedwireWaitOutcome::Cancelled);
529 }
530 }
531 }
532 }
533
534 pub(crate) fn redwire_queue_wait_cap_check(&self, wait_ms: u64) -> Result<(), String> {
543 let cap = self.config_u64(QUEUE_MAX_WAIT_MS_CONFIG_KEY, QUEUE_MAX_WAIT_MS_DEFAULT);
544 if wait_ms > cap {
545 Err(format!(
546 "queue-wait WAIT {wait_ms}ms exceeds server cap {QUEUE_MAX_WAIT_MS_CONFIG_KEY} = {cap}ms"
547 ))
548 } else {
549 Ok(())
550 }
551 }
552
553 pub(crate) fn enqueue_event_payload(
554 &self,
555 queue: &str,
556 payload: Value,
557 ) -> RedDBResult<EntityId> {
558 let store = self.inner.db.store();
559 if store.get_collection(queue).is_none() {
561 crate::runtime::impl_ddl::ensure_event_target_queue_pub(self, queue)?;
562 }
563
564 let payload_bytes = estimate_payload_bytes(&payload);
566 let outbox_bytes = OUTBOX_APPROX_BYTES.fetch_add(payload_bytes, Ordering::Relaxed);
567
568 if outbox_bytes > OUTBOX_MAX_BYTES {
570 OUTBOX_APPROX_BYTES.fetch_sub(payload_bytes, Ordering::Relaxed);
571 EVENTS_DRAIN_RETRIES_TOTAL.fetch_add(1, Ordering::Relaxed);
572 return self.route_event_to_outbox_dlq(queue, payload, "outbox_max_bytes_exceeded");
573 }
574
575 if outbox_bytes > OUTBOX_WARN_BYTES && outbox_bytes - payload_bytes <= OUTBOX_WARN_BYTES {
577 tracing::warn!(
578 outbox_bytes,
579 warn_threshold = OUTBOX_WARN_BYTES,
580 "event outbox approaching capacity warning threshold"
581 );
582 crate::telemetry::operator_event::OperatorEvent::OutboxDlqActivated {
583 queue: queue.to_string(),
584 dlq: format!("{queue}_outbox_dlq"),
585 reason: "outbox_warn_bytes_exceeded".to_string(),
586 }
587 .emit_global();
588 }
589
590 let config = load_queue_config(store.as_ref(), queue);
591
592 if let Some(max_size) = config.max_size {
594 let current_len = load_queue_message_views(store.as_ref(), queue)
595 .unwrap_or_default()
596 .len();
597 if current_len >= max_size {
598 OUTBOX_APPROX_BYTES.fetch_sub(payload_bytes, Ordering::Relaxed);
599 EVENTS_DRAIN_RETRIES_TOTAL.fetch_add(1, Ordering::Relaxed);
600 return self.route_event_to_outbox_dlq(queue, payload, "queue_full");
601 }
602 if current_len * 10 >= max_size * 8 {
604 tracing::warn!(
605 queue = %queue,
606 size = current_len,
607 max = max_size,
608 "event target queue near capacity"
609 );
610 }
611 }
612
613 let id = self.enqueue_event_payload_raw(store.as_ref(), queue, &config, payload)?;
614 EVENTS_ENQUEUED_TOTAL.fetch_add(1, Ordering::Relaxed);
615 Ok(id)
616 }
617
618 fn route_event_to_outbox_dlq(
620 &self,
621 queue: &str,
622 payload: Value,
623 reason: &str,
624 ) -> RedDBResult<EntityId> {
625 let dlq_name = format!("{queue}_outbox_dlq");
626 EVENTS_DLQ_TOTAL.fetch_add(1, Ordering::Relaxed);
627
628 crate::telemetry::operator_event::OperatorEvent::OutboxDlqActivated {
629 queue: queue.to_string(),
630 dlq: dlq_name.clone(),
631 reason: reason.to_string(),
632 }
633 .emit_global();
634
635 let store = self.inner.db.store();
636 if store.get_collection(&dlq_name).is_none() {
637 crate::runtime::impl_ddl::ensure_event_target_queue_pub(self, &dlq_name)?;
638 }
639 let dlq_config = load_queue_config(store.as_ref(), &dlq_name);
640 let id = self.enqueue_event_payload_raw(store.as_ref(), &dlq_name, &dlq_config, payload)?;
641 EVENTS_ENQUEUED_TOTAL.fetch_add(1, Ordering::Relaxed);
642 Ok(id)
643 }
644
645 fn enqueue_event_payload_raw(
647 &self,
648 store: &UnifiedStore,
649 queue: &str,
650 config: &QueueRuntimeConfig,
651 payload: Value,
652 ) -> RedDBResult<EntityId> {
653 let position = next_queue_position(store, queue, QueueSide::Right)?;
654 let mut entity = UnifiedEntity::new(
655 EntityId::new(0),
656 EntityKind::QueueMessage {
657 queue: queue.to_string(),
658 position,
659 },
660 EntityData::QueueMessage(QueueMessageData {
661 payload,
662 priority: None,
663 enqueued_at_ns: now_ns(),
664 attempts: 0,
665 max_attempts: config.max_attempts,
666 acked: false,
667 }),
668 );
669 if let Some(xid) = self.current_xid() {
670 entity.set_xmin(xid);
671 }
672 let id = store
673 .insert_auto(queue, entity)
674 .map_err(|err| RedDBError::Internal(err.to_string()))?;
675 if let Some(ttl_ms) = config.ttl_ms {
676 store
677 .set_metadata(queue, id, queue_message_ttl_metadata(ttl_ms))
678 .map_err(|err| RedDBError::Internal(err.to_string()))?;
679 }
680 self.invalidate_result_cache_for_table(queue);
681 Ok(id)
682 }
683
684 pub fn execute_create_queue(
685 &self,
686 raw_query: &str,
687 query: &CreateQueueQuery,
688 ) -> RedDBResult<RuntimeQueryResult> {
689 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
690 if super::impl_ddl::is_system_schema_name(&query.name) {
691 return Err(RedDBError::Query("system schema is read-only".to_string()));
692 }
693 if query.dlq.as_deref() == Some(query.name.as_str()) {
694 return Err(RedDBError::Query(
695 "dead-letter queue must be different from the source queue".to_string(),
696 ));
697 }
698
699 let store = self.inner.db.store();
700 let exists = store.get_collection(&query.name).is_some();
701 if exists {
702 if query.if_not_exists {
703 return Ok(RuntimeQueryResult::ok_message(
704 raw_query.to_string(),
705 &format!("queue '{}' already exists", query.name),
706 "create",
707 ));
708 }
709 return Err(RedDBError::Query(format!(
710 "queue '{}' already exists",
711 query.name
712 )));
713 }
714
715 store
716 .create_collection(&query.name)
717 .map_err(|err| RedDBError::Internal(err.to_string()))?;
718 if let Some(ttl_ms) = query.ttl_ms {
719 self.inner
720 .db
721 .set_collection_default_ttl_ms(&query.name, ttl_ms);
722 }
723 self.inner
724 .db
725 .save_collection_contract(queue_collection_contract(
726 &query.name,
727 query.priority,
728 query.ttl_ms,
729 ))
730 .map_err(|err| RedDBError::Internal(err.to_string()))?;
731 save_queue_config(
732 store.as_ref(),
733 &query.name,
734 &QueueRuntimeConfig {
735 mode: query.mode,
736 priority: query.priority,
737 max_size: query.max_size,
738 ttl_ms: query.ttl_ms,
739 dlq: query.dlq.clone(),
740 max_attempts: query.max_attempts,
741 lock_deadline_ms: query.lock_deadline_ms,
742 in_flight_cap_per_group: query.in_flight_cap_per_group,
743 retry_delay_ms: query.retry_delay_ms,
744 dedup_window_ms: query.dedup_window_ms,
745 },
746 )?;
747
748 if let Some(dlq) = &query.dlq {
749 if store.get_collection(dlq).is_none() {
750 store
751 .create_collection(dlq)
752 .map_err(|err| RedDBError::Internal(err.to_string()))?;
753 self.inner
754 .db
755 .save_collection_contract(queue_collection_contract(dlq, false, None))
756 .map_err(|err| RedDBError::Internal(err.to_string()))?;
757 }
758 }
759
760 self.invalidate_result_cache();
761 self.inner
762 .db
763 .persist_metadata()
764 .map_err(|err| RedDBError::Internal(err.to_string()))?;
765 let mut type_tags = Vec::new();
770 if let Some(dlq) = &query.dlq {
771 type_tags.push(format!("dlq:{}", dlq));
772 }
773 self.schema_vocabulary_apply(
774 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
775 collection: query.name.clone(),
776 columns: vec!["payload".to_string()],
777 type_tags,
778 description: None,
779 },
780 );
781
782 let mut msg = format!("queue '{}' created", query.name);
783 msg.push_str(&format!(" (mode={})", query.mode.as_str()));
784 if query.priority {
785 msg.push_str(" (priority)");
786 }
787 if let Some(max_size) = query.max_size {
788 msg.push_str(&format!(" (max_size={max_size})"));
789 }
790 if let Some(ttl_ms) = query.ttl_ms {
791 msg.push_str(&format!(" (ttl={ttl_ms}ms)"));
792 }
793 if let Some(dlq) = &query.dlq {
794 msg.push_str(&format!(
795 " (dlq={dlq}, max_attempts={})",
796 query.max_attempts
797 ));
798 }
799
800 Ok(RuntimeQueryResult::ok_message(
801 raw_query.to_string(),
802 &msg,
803 "create",
804 ))
805 }
806
807 pub fn execute_alter_queue(
808 &self,
809 raw_query: &str,
810 query: &AlterQueueQuery,
811 ) -> RedDBResult<RuntimeQueryResult> {
812 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
813 let store = self.inner.db.store();
814 ensure_queue_exists(store.as_ref(), &query.name)?;
815
816 let mut config = load_queue_config(store.as_ref(), &query.name);
817 let mut summary: Vec<String> = Vec::new();
818
819 if let Some(new_mode) = query.mode {
820 let pending =
821 load_pending_entries(store.as_ref(), &query.name, None, None).unwrap_or_default();
822 if !pending.is_empty() {
823 tracing::warn!(
824 queue = %query.name,
825 pending_count = pending.len(),
826 new_mode = %new_mode.as_str(),
827 "ALTER QUEUE SET MODE: {} in-flight messages will drain with old mode; \
828 new reads use {}",
829 pending.len(),
830 new_mode.as_str(),
831 );
832 }
833 config.mode = new_mode;
834 summary.push(format!("mode={}", new_mode.as_str()));
835 }
836 if let Some(max_attempts) = query.max_attempts {
837 config.max_attempts = max_attempts;
838 summary.push(format!("max_attempts={max_attempts}"));
839 }
840 if let Some(lock_deadline_ms) = query.lock_deadline_ms {
841 config.lock_deadline_ms = lock_deadline_ms;
842 summary.push(format!("lock_deadline_ms={lock_deadline_ms}"));
843 }
844 if let Some(in_flight_cap) = query.in_flight_cap_per_group {
845 config.in_flight_cap_per_group = in_flight_cap;
846 summary.push(format!("in_flight_cap_per_group={in_flight_cap}"));
847 }
848 if let Some(dlq) = &query.dlq {
849 if dlq == &query.name {
850 return Err(RedDBError::Query(
851 "dead-letter queue must be different from the source queue".to_string(),
852 ));
853 }
854 config.dlq = Some(dlq.clone());
855 summary.push(format!("dlq={dlq}"));
856 }
857 if let Some(retry_delay_ms) = query.retry_delay_ms {
858 config.retry_delay_ms = if retry_delay_ms == 0 {
859 None
860 } else {
861 Some(retry_delay_ms)
862 };
863 summary.push(format!("retry_delay_ms={retry_delay_ms}"));
864 }
865 if let Some(dedup_window_ms) = query.dedup_window_ms {
866 config.dedup_window_ms = Some(dedup_window_ms);
867 summary.push(format!("dedup_window_ms={dedup_window_ms}"));
868 }
869
870 save_queue_config(store.as_ref(), &query.name, &config)?;
871
872 self.invalidate_result_cache();
873 self.inner
874 .db
875 .persist_metadata()
876 .map_err(|err| RedDBError::Internal(err.to_string()))?;
877
878 Ok(RuntimeQueryResult::ok_message(
879 raw_query.to_string(),
880 &format!("queue '{}' altered: {}", query.name, summary.join(", ")),
881 "alter",
882 ))
883 }
884
885 pub fn execute_drop_queue(
886 &self,
887 raw_query: &str,
888 query: &DropQueueQuery,
889 ) -> RedDBResult<RuntimeQueryResult> {
890 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
891 let store = self.inner.db.store();
892 if super::impl_ddl::is_system_schema_name(&query.name) {
893 return Err(RedDBError::Query("system schema is read-only".to_string()));
894 }
895 if store.get_collection(&query.name).is_none() {
896 if query.if_exists {
897 return Ok(RuntimeQueryResult::ok_message(
898 raw_query.to_string(),
899 &format!("queue '{}' does not exist", query.name),
900 "drop",
901 ));
902 }
903 return Err(RedDBError::NotFound(format!(
904 "queue '{}' not found",
905 query.name
906 )));
907 }
908 let actual = crate::runtime::ddl::polymorphic_resolver::resolve(
909 &query.name,
910 &self.inner.db.catalog_model_snapshot(),
911 )?;
912 crate::runtime::ddl::polymorphic_resolver::ensure_model_match(
913 crate::catalog::CollectionModel::Queue,
914 actual,
915 )?;
916
917 store
918 .drop_collection(&query.name)
919 .map_err(|err| RedDBError::Internal(err.to_string()))?;
920 self.inner.db.clear_collection_default_ttl_ms(&query.name);
921 self.inner
922 .db
923 .remove_collection_contract(&query.name)
924 .map_err(|err| RedDBError::Internal(err.to_string()))?;
925 remove_queue_metadata(store.as_ref(), &query.name);
926 self.invalidate_result_cache();
927 self.inner
928 .db
929 .persist_metadata()
930 .map_err(|err| RedDBError::Internal(err.to_string()))?;
931 self.schema_vocabulary_apply(
933 crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
934 collection: query.name.clone(),
935 },
936 );
937
938 Ok(RuntimeQueryResult::ok_message(
939 raw_query.to_string(),
940 &format!("queue '{}' dropped", query.name),
941 "drop",
942 ))
943 }
944
945 pub fn execute_queue_command(
946 &self,
947 raw_query: &str,
948 cmd: &QueueCommand,
949 ) -> RedDBResult<RuntimeQueryResult> {
950 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
951 match cmd {
952 QueueCommand::Push {
953 queue,
954 value,
955 side,
956 priority,
957 key,
958 available,
959 dedup_key,
960 } => {
961 let store = self.inner.db.store();
962 ensure_queue_exists(store.as_ref(), queue)?;
963 let config = load_queue_config(store.as_ref(), queue);
964 if key.is_some() && config.priority {
965 return Err(RedDBError::Query(format!(
966 "ordering key is not supported on priority queue '{}'",
967 queue
968 )));
969 }
970 if priority.is_some() && !config.priority {
971 return Err(RedDBError::Query(format!(
972 "queue '{}' is not a priority queue",
973 queue
974 )));
975 }
976 if let Some(dedup_key) = dedup_key.as_deref() {
977 let now = now_ns();
978 reclaim_expired_queue_dedup(store.as_ref(), queue, now)?;
979 if let Some(existing_id) =
980 find_queue_dedup(store.as_ref(), queue, dedup_key, now)
981 {
982 let mut result = UnifiedResult::with_columns(vec![
983 "message_id".into(),
984 "side".into(),
985 "queue".into(),
986 ]);
987 let mut record = UnifiedRecord::new();
988 record.set("message_id", Value::text(message_id_string(existing_id)));
989 record.set(
990 "side",
991 Value::text(match side {
992 QueueSide::Left => "left".to_string(),
993 QueueSide::Right => "right".to_string(),
994 }),
995 );
996 record.set("queue", Value::text(queue.clone()));
997 result.push(record);
998
999 return Ok(RuntimeQueryResult {
1000 query: raw_query.to_string(),
1001 mode: QueryMode::Sql,
1002 statement: "queue_push",
1003 engine: "runtime-queue",
1004 result,
1005 affected_rows: 1,
1006 statement_type: "insert",
1007 bookmark: None,
1008 notice: None,
1009 });
1010 }
1011 }
1012 if let Some(max_size) = config.max_size {
1013 let current_len =
1014 load_queue_message_views_with_runtime(Some(self), store.as_ref(), queue)?
1015 .len();
1016 if current_len >= max_size {
1017 return Err(RedDBError::Query(format!(
1018 "queue '{}' is full (max_size={max_size})",
1019 queue
1020 )));
1021 }
1022 }
1023
1024 let position = next_queue_position(store.as_ref(), queue, *side)?;
1025 let mut entity = UnifiedEntity::new(
1026 EntityId::new(0),
1027 EntityKind::QueueMessage {
1028 queue: queue.clone(),
1029 position,
1030 },
1031 EntityData::QueueMessage(QueueMessageData {
1032 payload: value.clone(),
1033 priority: if config.priority { *priority } else { None },
1034 enqueued_at_ns: now_ns(),
1035 attempts: 0,
1036 max_attempts: config.max_attempts,
1037 acked: false,
1038 }),
1039 );
1040 if let Some(xid) = self.current_xid() {
1043 entity.set_xmin(xid);
1044 }
1045 let id = store
1046 .insert_auto(queue, entity)
1047 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1048 if let Some(dedup_key) = dedup_key.as_deref() {
1049 let window_ms = config
1050 .dedup_window_ms
1051 .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_DEDUP_WINDOW_MS);
1052 let expires_at_ns =
1053 now_ns().saturating_add(window_ms.saturating_mul(1_000_000));
1054 let metadata_id =
1055 record_queue_dedup(store.as_ref(), queue, dedup_key, id, expires_at_ns)?;
1056 self.stamp_xmin_if_in_txn(QUEUE_META_COLLECTION, metadata_id);
1057 if self.current_xid().is_some() {
1058 self.record_pending_queue_dedup(
1059 current_connection_id(),
1060 queue,
1061 dedup_key,
1062 metadata_id,
1063 );
1064 }
1065 }
1066 let available_at_ns = available.map(|a| match a {
1071 reddb_rql::ast::QueueAvailability::DelayMs(ms) => {
1072 now_ns().saturating_add(ms.saturating_mul(1_000_000))
1073 }
1074 reddb_rql::ast::QueueAvailability::AtUnixMs(ms) => ms.saturating_mul(1_000_000),
1075 });
1076 if config.ttl_ms.is_some() || available_at_ns.is_some() || key.is_some() {
1077 store
1078 .set_metadata(
1079 queue,
1080 id,
1081 queue_message_metadata(config.ttl_ms, available_at_ns, key.as_deref()),
1082 )
1083 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1084 }
1085 self.record_queue_wake(&queue_wait_scope(), queue);
1090 self.invalidate_result_cache();
1091
1092 let mut result = UnifiedResult::with_columns(vec![
1093 "message_id".into(),
1094 "side".into(),
1095 "queue".into(),
1096 ]);
1097 let mut record = UnifiedRecord::new();
1098 record.set("message_id", Value::text(message_id_string(id)));
1099 record.set(
1100 "side",
1101 Value::text(match side {
1102 QueueSide::Left => "left".to_string(),
1103 QueueSide::Right => "right".to_string(),
1104 }),
1105 );
1106 record.set("queue", Value::text(queue.clone()));
1107 result.push(record);
1108
1109 Ok(RuntimeQueryResult {
1110 query: raw_query.to_string(),
1111 mode: QueryMode::Sql,
1112 statement: "queue_push",
1113 engine: "runtime-queue",
1114 result,
1115 affected_rows: 1,
1116 statement_type: "insert",
1117 bookmark: None,
1118 notice: None,
1119 })
1120 }
1121 QueueCommand::Pop { queue, side, count } => {
1122 let store = self.inner.db.store();
1123 ensure_queue_exists(store.as_ref(), queue)?;
1124 let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1125 let popped = lifecycle
1126 .pop(queue, ast_side_to_lc(*side), *count, &txn)
1127 .map_err(map_qse)?;
1128
1129 let mut result =
1130 UnifiedResult::with_columns(vec!["message_id".into(), "payload".into()]);
1131 for (message_id, payload) in &popped {
1132 let mut record = UnifiedRecord::new();
1133 record.set(
1134 "message_id",
1135 Value::text(message_id_string(EntityId::new(*message_id))),
1136 );
1137 record.set("payload", payload.clone());
1138 result.push(record);
1139 }
1140 let popped_count = popped.len() as u64;
1141 if popped_count > 0 {
1142 self.invalidate_result_cache();
1143 }
1144
1145 Ok(RuntimeQueryResult {
1146 query: raw_query.to_string(),
1147 mode: QueryMode::Sql,
1148 statement: "queue_pop",
1149 engine: "runtime-queue",
1150 result,
1151 affected_rows: popped_count,
1152 statement_type: "delete",
1153 bookmark: None,
1154 notice: None,
1155 })
1156 }
1157 QueueCommand::Peek { queue, count } => {
1158 let store = self.inner.db.store();
1159 ensure_queue_exists(store.as_ref(), queue)?;
1160 let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1161 let messages = lifecycle.peek(queue, *count, &txn);
1162
1163 let mut result =
1164 UnifiedResult::with_columns(vec!["message_id".into(), "payload".into()]);
1165 for message in messages {
1166 let mut record = UnifiedRecord::new();
1167 record.set(
1168 "message_id",
1169 Value::text(message_id_string(EntityId::new(message.message_id))),
1170 );
1171 record.set("payload", message.payload);
1172 result.push(record);
1173 }
1174
1175 Ok(RuntimeQueryResult {
1176 query: raw_query.to_string(),
1177 mode: QueryMode::Sql,
1178 statement: "queue_peek",
1179 engine: "runtime-queue",
1180 result,
1181 affected_rows: 0,
1182 statement_type: "select",
1183 bookmark: None,
1184 notice: None,
1185 })
1186 }
1187 QueueCommand::Len { queue } => {
1188 let store = self.inner.db.store();
1189 ensure_queue_exists(store.as_ref(), queue)?;
1190 let count =
1191 load_queue_message_views_with_runtime(Some(self), store.as_ref(), queue)?.len()
1192 as u64;
1193 let mut result = UnifiedResult::with_columns(vec!["len".into()]);
1194 let mut record = UnifiedRecord::new();
1195 record.set("len", Value::UnsignedInteger(count));
1196 result.push(record);
1197
1198 Ok(RuntimeQueryResult {
1199 query: raw_query.to_string(),
1200 mode: QueryMode::Sql,
1201 statement: "queue_len",
1202 engine: "runtime-queue",
1203 result,
1204 affected_rows: 0,
1205 statement_type: "select",
1206 bookmark: None,
1207 notice: None,
1208 })
1209 }
1210 QueueCommand::Purge { queue } => {
1211 let store = self.inner.db.store();
1212 ensure_queue_exists(store.as_ref(), queue)?;
1213 let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1214 let count = lifecycle.purge(queue, &txn).map_err(map_qse)?;
1215 if count > 0 {
1216 self.invalidate_result_cache();
1217 }
1218
1219 Ok(RuntimeQueryResult::ok_message(
1220 raw_query.to_string(),
1221 &format!("{count} messages purged from queue '{queue}'"),
1222 "delete",
1223 ))
1224 }
1225 QueueCommand::GroupCreate { queue, group } => {
1226 let store = self.inner.db.store();
1227 ensure_queue_exists(store.as_ref(), queue)?;
1228 if queue_group_exists(store.as_ref(), queue, group)? {
1229 return Ok(RuntimeQueryResult::ok_message(
1230 raw_query.to_string(),
1231 &format!(
1232 "consumer group '{}' already exists on queue '{}'",
1233 group, queue
1234 ),
1235 "create",
1236 ));
1237 }
1238 save_queue_group(store.as_ref(), queue, group)?;
1239 self.invalidate_result_cache();
1240
1241 Ok(RuntimeQueryResult::ok_message(
1242 raw_query.to_string(),
1243 &format!("consumer group '{}' created on queue '{}'", group, queue),
1244 "create",
1245 ))
1246 }
1247 QueueCommand::GroupRead {
1248 queue,
1249 group,
1250 consumer,
1251 count,
1252 wait_ms,
1253 } => {
1254 let store = self.inner.db.store();
1255 ensure_queue_exists(store.as_ref(), queue)?;
1256 if let Some(ms) = *wait_ms {
1262 if self.current_xid().is_some() {
1263 return Err(RedDBError::Query(
1264 "QUEUE READ … WAIT is autocommit-only: refusing to park inside an explicit transaction (BEGIN/COMMIT)"
1265 .to_string(),
1266 ));
1267 }
1268 let cap =
1269 self.config_u64(QUEUE_MAX_WAIT_MS_CONFIG_KEY, QUEUE_MAX_WAIT_MS_DEFAULT);
1270 if ms > cap {
1271 return Err(RedDBError::Query(format!(
1272 "QUEUE READ … WAIT {ms}ms exceeds server cap {QUEUE_MAX_WAIT_MS_CONFIG_KEY} = {cap}ms"
1273 )));
1274 }
1275 }
1276 let config = load_queue_config(store.as_ref(), queue);
1280 let group_owned =
1281 resolve_read_group(store.as_ref(), queue, group.as_deref(), consumer, &config)?;
1282 let group_ref = group_owned.as_str();
1283 let delivered = self
1284 .group_read_with_optional_wait(queue, group_ref, consumer, *count, *wait_ms)?;
1285
1286 {
1290 let lease_count = u32::try_from(delivered.len()).unwrap_or(u32::MAX);
1291 let now_ns = std::time::SystemTime::now()
1292 .duration_since(std::time::UNIX_EPOCH)
1293 .map(|d| d.as_nanos() as u64)
1294 .unwrap_or(0);
1295 self.queue_presence().heartbeat(
1296 queue,
1297 group_ref,
1298 consumer,
1299 lease_count,
1300 now_ns,
1301 );
1302 }
1303
1304 let mut result = UnifiedResult::with_columns(vec![
1305 "message_id".into(),
1306 "payload".into(),
1307 "consumer".into(),
1308 "delivery_count".into(),
1309 "attempts".into(),
1310 ]);
1311
1312 for message in delivered {
1313 let mut record = UnifiedRecord::new();
1314 record.set(
1315 "message_id",
1316 Value::text(message_id_string(EntityId::new(message.message_id))),
1317 );
1318 record.set("payload", message.payload);
1319 record.set("consumer", Value::text(message.consumer));
1320 record.set(
1321 "delivery_count",
1322 Value::UnsignedInteger(u64::from(message.delivery_count)),
1323 );
1324 record.set(
1325 "attempts",
1326 Value::UnsignedInteger(u64::from(message.delivery_count)),
1327 );
1328 result.push(record);
1329 }
1330 if !result.records.is_empty() {
1331 self.invalidate_result_cache();
1332 }
1333
1334 Ok(RuntimeQueryResult {
1335 query: raw_query.to_string(),
1336 mode: QueryMode::Sql,
1337 statement: "queue_group_read",
1338 engine: "runtime-queue",
1339 result,
1340 affected_rows: 0,
1341 statement_type: "select",
1342 bookmark: None,
1343 notice: None,
1344 })
1345 }
1346 QueueCommand::Pending { queue, group } => {
1347 let store = self.inner.db.store();
1348 ensure_queue_exists(store.as_ref(), queue)?;
1349 require_queue_group(store.as_ref(), queue, group)?;
1350 let mut pending = load_pending_entries(store.as_ref(), queue, Some(group), None)?;
1351 pending.sort_by_key(|entry| entry.delivered_at_ns);
1352 let current_time_ns = now_ns();
1353
1354 let mut result = UnifiedResult::with_columns(vec![
1355 "message_id".into(),
1356 "consumer".into(),
1357 "delivered_at_ns".into(),
1358 "delivery_count".into(),
1359 "idle_ms".into(),
1360 ]);
1361 for entry in pending {
1362 let mut record = UnifiedRecord::new();
1363 record.set(
1364 "message_id",
1365 Value::text(message_id_string(entry.message_id)),
1366 );
1367 record.set("consumer", Value::text(entry.consumer));
1368 record.set(
1369 "delivered_at_ns",
1370 Value::UnsignedInteger(entry.delivered_at_ns),
1371 );
1372 record.set(
1373 "delivery_count",
1374 Value::UnsignedInteger(u64::from(entry.delivery_count)),
1375 );
1376 record.set(
1377 "idle_ms",
1378 Value::UnsignedInteger(
1379 current_time_ns.saturating_sub(entry.delivered_at_ns) / 1_000_000,
1380 ),
1381 );
1382 result.push(record);
1383 }
1384
1385 Ok(RuntimeQueryResult {
1386 query: raw_query.to_string(),
1387 mode: QueryMode::Sql,
1388 statement: "queue_pending",
1389 engine: "runtime-queue",
1390 result,
1391 affected_rows: 0,
1392 statement_type: "select",
1393 bookmark: None,
1394 notice: None,
1395 })
1396 }
1397 QueueCommand::Claim {
1398 queue,
1399 group,
1400 consumer,
1401 min_idle_ms,
1402 } => {
1403 let store = self.inner.db.store();
1404 ensure_queue_exists(store.as_ref(), queue)?;
1405 require_queue_group(store.as_ref(), queue, group)?;
1406 let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1407 let delivered = lifecycle
1408 .claim_delivering(queue, consumer, *min_idle_ms, &txn)
1409 .map_err(map_qse)?;
1410
1411 let mut result = UnifiedResult::with_columns(vec![
1412 "message_id".into(),
1413 "delivery_id".into(),
1414 "payload".into(),
1415 "consumer".into(),
1416 "delivery_count".into(),
1417 ]);
1418
1419 for message in delivered {
1420 let mut record = UnifiedRecord::new();
1421 record.set(
1422 "message_id",
1423 Value::text(message_id_string(EntityId::new(message.message_id))),
1424 );
1425 record.set("delivery_id", Value::text(message.delivery_id));
1426 record.set("payload", message.payload);
1427 record.set("consumer", Value::text(message.consumer));
1428 record.set(
1429 "delivery_count",
1430 Value::UnsignedInteger(u64::from(message.delivery_count)),
1431 );
1432 result.push(record);
1433 }
1434 if !result.records.is_empty() {
1435 self.invalidate_result_cache();
1436 }
1437 let affected_rows = result.records.len() as u64;
1438
1439 Ok(RuntimeQueryResult {
1440 query: raw_query.to_string(),
1441 mode: QueryMode::Sql,
1442 statement: "queue_claim",
1443 engine: "runtime-queue",
1444 result,
1445 affected_rows,
1446 statement_type: "update",
1447 bookmark: None,
1448 notice: None,
1449 })
1450 }
1451 QueueCommand::Ack {
1452 queue,
1453 group,
1454 message_id,
1455 delivery_id,
1456 } => {
1457 let store = self.inner.db.store();
1458 ensure_queue_exists(store.as_ref(), queue)?;
1459 let (group_owned, message_entity) = resolve_ack_nack_handle(
1460 store.as_ref(),
1461 queue,
1462 group,
1463 message_id,
1464 delivery_id.as_deref(),
1465 )?;
1466 let group_ref = group_owned.as_str();
1467 require_queue_group(store.as_ref(), queue, group_ref)?;
1468 let (lifecycle, ps, txn) = runtime_lifecycle(self, queue);
1469 let did = match delivery_id.as_deref() {
1470 Some(d) => d.to_string(),
1471 None => ps
1472 .find_pending_by_key(queue, message_entity.raw(), group_ref)
1473 .ok_or_else(|| {
1474 RedDBError::NotFound(format!(
1475 "no pending delivery for message '{}' on queue '{}' (group '{}')",
1476 message_entity.raw(),
1477 queue,
1478 group_ref
1479 ))
1480 })?,
1481 };
1482 lifecycle.ack(&txn, &did).map_err(map_qse)?;
1483 self.invalidate_result_cache();
1484
1485 Ok(RuntimeQueryResult::ok_message(
1486 raw_query.to_string(),
1487 "message acknowledged",
1488 "update",
1489 ))
1490 }
1491 QueueCommand::Nack {
1492 queue,
1493 group,
1494 message_id,
1495 delivery_id,
1496 delay_ms,
1497 } => {
1498 let store = self.inner.db.store();
1499 ensure_queue_exists(store.as_ref(), queue)?;
1500 let config = load_queue_config(store.as_ref(), queue);
1501 if delay_ms.is_some() {
1507 if let Some((_, role)) = current_auth_identity() {
1508 if !role.can_write() {
1509 return Err(RedDBError::InvalidOperation(format!(
1510 "role '{role}' is not authorized to override NACK retry delay on queue '{queue}'"
1511 )));
1512 }
1513 }
1514 }
1515 let (group_owned, message_entity) = resolve_ack_nack_handle(
1516 store.as_ref(),
1517 queue,
1518 group,
1519 message_id,
1520 delivery_id.as_deref(),
1521 )?;
1522 let group_ref = group_owned.as_str();
1523 require_queue_group(store.as_ref(), queue, group_ref)?;
1524 let (lifecycle, ps, txn) = runtime_lifecycle(self, queue);
1525 let did = match delivery_id.as_deref() {
1526 Some(d) => d.to_string(),
1527 None => ps
1528 .find_pending_by_key(queue, message_entity.raw(), group_ref)
1529 .ok_or_else(|| {
1530 RedDBError::NotFound(format!(
1531 "no pending delivery for message '{}' on queue '{}' (group '{}')",
1532 message_entity.raw(),
1533 queue,
1534 group_ref
1535 ))
1536 })?,
1537 };
1538 let effective_delay_ms = delay_ms.or(config.retry_delay_ms).unwrap_or(0);
1542 let pending_attempt = ps.read_pending_attempt(&did).map_err(map_qse)?;
1543 let nack_attempts = pending_attempt.attempts.saturating_add(1);
1544 let retry_available_at_ns = if effective_delay_ms > 0 {
1545 Some(now_ns().saturating_add(effective_delay_ms.saturating_mul(1_000_000)))
1546 } else {
1547 None
1548 };
1549 let retry_deadline = if effective_delay_ms > 0 {
1550 Some(
1551 std::time::Instant::now()
1552 + std::time::Duration::from_millis(effective_delay_ms),
1553 )
1554 } else {
1555 None
1556 };
1557 let outcome = lifecycle
1558 .nack_with_retry_deadline(&txn, &did, retry_deadline)
1559 .map_err(map_qse)?;
1560 if matches!(outcome, RetirementOutcome::Requeued) && effective_delay_ms > 0 {
1564 set_message_available_at_ns(
1565 store.as_ref(),
1566 queue,
1567 message_entity,
1568 retry_available_at_ns,
1569 config.ttl_ms,
1570 )?;
1571 }
1572 self.maybe_emit_nack_audit(
1579 queue,
1580 group_ref,
1581 &did,
1582 *delay_ms,
1583 config.retry_delay_ms,
1584 &outcome,
1585 );
1586 let outcome_label = match &outcome {
1587 RetirementOutcome::Requeued => NackOutcomeLabel::Retry,
1588 RetirementOutcome::MovedToDlq(_) => NackOutcomeLabel::Dlq,
1589 RetirementOutcome::Dropped => NackOutcomeLabel::Drop,
1590 };
1591 self.queue_telemetry().record_nacked(
1592 queue,
1593 group_ref,
1594 config.mode.as_str(),
1595 outcome_label,
1596 );
1597 if let RetirementOutcome::MovedToDlq(dlq) = &outcome {
1598 OperatorEvent::QueueDlqPromoted {
1599 queue: queue.to_string(),
1600 group: group_ref.to_string(),
1601 dlq: dlq.clone(),
1602 message_id: pending_attempt.message_id,
1603 attempts: nack_attempts,
1604 reason: format!("lifecycle_nack:{did}"),
1605 }
1606 .emit(self.audit_log());
1607 }
1608 let message = match outcome {
1609 RetirementOutcome::Requeued => {
1610 if effective_delay_ms > 0 {
1611 format!("message requeued (delay={effective_delay_ms}ms)")
1612 } else {
1613 "message requeued".to_string()
1614 }
1615 }
1616 RetirementOutcome::MovedToDlq(dlq) => {
1617 format!("message moved to dead-letter queue '{}'", dlq)
1618 }
1619 RetirementOutcome::Dropped => "message dropped after max attempts".to_string(),
1620 };
1621 self.invalidate_result_cache();
1622
1623 Ok(RuntimeQueryResult::ok_message(
1624 raw_query.to_string(),
1625 &message,
1626 "update",
1627 ))
1628 }
1629 QueueCommand::Move {
1630 source,
1631 destination,
1632 filter,
1633 limit,
1634 } => self.execute_queue_move(raw_query, source, destination, filter.as_ref(), *limit),
1635 }
1636 }
1637
1638 pub fn execute_queue_select(
1639 &self,
1640 raw_query: &str,
1641 query: &QueueSelectQuery,
1642 ) -> RedDBResult<RuntimeQueryResult> {
1643 let store = self.inner.db.store();
1644 ensure_queue_exists(store.as_ref(), &query.queue)?;
1645 let config = load_queue_config(store.as_ref(), &query.queue);
1646 let dlq = queue_is_dead_letter_target(store.as_ref(), &query.queue);
1647 let columns = if query.columns.is_empty() {
1648 queue_projection_default_columns()
1649 } else {
1650 query.columns.clone()
1651 };
1652
1653 let mut messages =
1654 load_queue_message_views_with_runtime(Some(self), store.as_ref(), &query.queue)?;
1655 sort_queue_messages(&mut messages, &config, QueueSide::Left);
1656
1657 let mut result = UnifiedResult::with_columns(columns.clone());
1658 for message in messages {
1659 if query
1660 .filter
1661 .as_ref()
1662 .is_some_and(|filter| !queue_message_matches_filter(&message, dlq, filter))
1663 {
1664 continue;
1665 }
1666 let record = queue_projection_record(&columns, &message, dlq)?;
1667 result.push(record);
1668 if query
1669 .limit
1670 .is_some_and(|limit| result.records.len() >= limit as usize)
1671 {
1672 break;
1673 }
1674 }
1675
1676 Ok(RuntimeQueryResult {
1677 query: raw_query.to_string(),
1678 mode: QueryMode::Sql,
1679 statement: "queue_select",
1680 engine: "runtime-queue",
1681 result,
1682 affected_rows: 0,
1683 statement_type: "select",
1684 bookmark: None,
1685 notice: None,
1686 })
1687 }
1688
1689 fn execute_queue_move(
1690 &self,
1691 raw_query: &str,
1692 source: &str,
1693 destination: &str,
1694 filter: Option<&Filter>,
1695 limit: usize,
1696 ) -> RedDBResult<RuntimeQueryResult> {
1697 if source == destination {
1698 return Err(RedDBError::Query(
1699 "QUEUE MOVE source and destination must be different".to_string(),
1700 ));
1701 }
1702 let store = self.inner.db.store();
1703 ensure_queue_exists(store.as_ref(), source)?;
1704 ensure_queue_exists(store.as_ref(), destination)?;
1705 let source_config = load_queue_config(store.as_ref(), source);
1706 let destination_config = load_queue_config(store.as_ref(), destination);
1707 let source_dlq = queue_is_dead_letter_target(store.as_ref(), source);
1708
1709 let mut messages =
1710 load_queue_message_views_with_runtime(Some(self), store.as_ref(), source)?;
1711 sort_queue_messages(&mut messages, &source_config, QueueSide::Left);
1712 let selected = messages
1713 .into_iter()
1714 .filter(|message| {
1715 filter
1716 .map(|f| queue_message_matches_filter(message, source_dlq, f))
1717 .unwrap_or(true)
1718 })
1719 .take(limit)
1720 .collect::<Vec<_>>();
1721
1722 if let Some(max_size) = destination_config.max_size {
1723 let current_len =
1724 load_queue_message_views_with_runtime(Some(self), store.as_ref(), destination)?
1725 .len();
1726 if current_len + selected.len() > max_size {
1727 return Err(RedDBError::Query(format!(
1728 "queue '{}' is full (max_size={max_size})",
1729 destination
1730 )));
1731 }
1732 }
1733
1734 for message in &selected {
1735 let lock = queue_message_lock_handle(self, source, message.id);
1736 let Some(_guard) = lock.try_lock() else {
1737 return Err(RedDBError::Query(format!(
1738 "message '{}' is locked on queue '{}'",
1739 message.id.raw(),
1740 source
1741 )));
1742 };
1743 if queue_message_view_by_id(store.as_ref(), source, message.id)?.is_none() {
1744 return Err(RedDBError::Query(format!(
1745 "message '{}' is no longer available on queue '{}'",
1746 message.id.raw(),
1747 source
1748 )));
1749 }
1750 }
1751
1752 let mut inserted = Vec::new();
1753 for message in &selected {
1754 match insert_moved_queue_message(
1755 store.as_ref(),
1756 destination,
1757 &destination_config,
1758 message,
1759 ) {
1760 Ok(id) => inserted.push(id),
1761 Err(err) => {
1762 for id in inserted {
1763 let _ = store.delete(destination, id);
1764 }
1765 return Err(err);
1766 }
1767 }
1768 }
1769
1770 let (move_lifecycle, _move_ps, move_txn) = runtime_lifecycle(self, source);
1771 for message in &selected {
1772 move_lifecycle
1773 .delete_with_state(source, message.id.raw(), &move_txn)
1774 .map_err(map_qse)?;
1775 }
1776 if !selected.is_empty() {
1777 self.invalidate_result_cache();
1778 }
1779
1780 let selected_count = selected.len() as u64;
1781 self.audit_log().record_event(
1782 AuditEvent::builder("queue/move")
1783 .source(AuditAuthSource::System)
1784 .outcome(Outcome::Success)
1785 .resource(format!("queue:{source}->{destination}"))
1786 .fields([
1787 AuditFieldEscaper::field("source", source),
1788 AuditFieldEscaper::field("destination", destination),
1789 AuditFieldEscaper::field("selected", selected_count),
1790 AuditFieldEscaper::field("committed", selected_count),
1791 ])
1792 .build(),
1793 );
1794
1795 let mut result = UnifiedResult::with_columns(vec![
1796 "source".into(),
1797 "destination".into(),
1798 "selected".into(),
1799 "committed".into(),
1800 ]);
1801 let mut record = UnifiedRecord::new();
1802 record.set("source", Value::text(source.to_string()));
1803 record.set("destination", Value::text(destination.to_string()));
1804 record.set("selected", Value::UnsignedInteger(selected_count));
1805 record.set("committed", Value::UnsignedInteger(selected_count));
1806 result.push(record);
1807
1808 Ok(RuntimeQueryResult {
1809 query: raw_query.to_string(),
1810 mode: QueryMode::Sql,
1811 statement: "queue_move",
1812 engine: "runtime-queue",
1813 result,
1814 affected_rows: selected_count,
1815 statement_type: "update",
1816 bookmark: None,
1817 notice: None,
1818 })
1819 }
1820
1821 fn maybe_emit_nack_audit(
1836 &self,
1837 queue: &str,
1838 group: &str,
1839 delivery_id: &str,
1840 override_ms: Option<u64>,
1841 default_ms: Option<u64>,
1842 outcome: &RetirementOutcome,
1843 ) {
1844 let Some(override_ms) = override_ms else {
1845 return;
1846 };
1847 let outcome_label = match outcome {
1848 RetirementOutcome::Requeued => "requeued",
1849 RetirementOutcome::MovedToDlq(_) => "dlq",
1850 RetirementOutcome::Dropped => "dropped",
1851 };
1852 const SIGNIFICANT_DELAY_MS: u64 = 60_000;
1853 let destination_changed = !matches!(outcome, RetirementOutcome::Requeued);
1854 if override_ms < SIGNIFICANT_DELAY_MS && !destination_changed {
1855 return;
1856 }
1857 self.audit_log().record_event(
1858 AuditEvent::builder("queue/nack/override")
1859 .source(AuditAuthSource::System)
1860 .outcome(Outcome::Success)
1861 .resource(format!("queue:{queue}"))
1862 .fields([
1863 AuditFieldEscaper::field("queue", queue),
1864 AuditFieldEscaper::field("group", group),
1865 AuditFieldEscaper::field("delivery_id", delivery_id),
1866 AuditFieldEscaper::field("override_delay_ms", override_ms),
1867 AuditFieldEscaper::field("default_delay_ms", default_ms.unwrap_or(0)),
1868 AuditFieldEscaper::field("outcome", outcome_label),
1869 ])
1870 .build(),
1871 );
1872 }
1873
1874 pub(super) fn is_queue_collection(&self, collection: &str) -> bool {
1878 self.db()
1879 .collection_contract_arc(collection)
1880 .map(|contract| contract.declared_model == crate::catalog::CollectionModel::Queue)
1881 .unwrap_or(false)
1882 }
1883
1884 pub(super) fn execute_queue_shaped_claim(
1898 &self,
1899 raw_query: &str,
1900 query: &UpdateQuery,
1901 ) -> RedDBResult<RuntimeQueryResult> {
1902 validate_queue_claim_shape(query)?;
1903
1904 let queue = query.table.as_str();
1905 let store = self.inner.db.store();
1906 ensure_queue_exists(store.as_ref(), queue)?;
1907
1908 let config = load_queue_config(store.as_ref(), queue);
1913 if config.mode != QueueMode::Work {
1914 return Err(RedDBError::InvalidOperation(format!(
1915 "CLAIM on queue '{queue}' cannot be expressed: FANOUT delivery \
1916 must be consumed through GROUP READ, not a queue-shaped CLAIM"
1917 )));
1918 }
1919
1920 let group = resolve_read_group(store.as_ref(), queue, None, "", &config)?;
1924 let count = query.claim_limit.unwrap_or(0) as usize;
1925 let (lifecycle, _ps, txn) = runtime_lifecycle(self, queue);
1926 let delivered = lifecycle
1927 .deliver(&txn, queue, &group, count)
1928 .map_err(map_qse)?;
1929
1930 let mut result = UnifiedResult::with_columns(vec!["delivery_id".into(), "payload".into()]);
1931 for message in &delivered {
1932 let mut record = UnifiedRecord::new();
1933 record.set("delivery_id", Value::text(message.delivery_id.clone()));
1934 record.set("payload", message.payload.clone());
1935 result.push(record);
1936 }
1937 let affected_rows = delivered.len() as u64;
1938 if affected_rows > 0 {
1939 self.invalidate_result_cache();
1940 }
1941
1942 Ok(RuntimeQueryResult {
1943 query: raw_query.to_string(),
1944 mode: QueryMode::Sql,
1945 statement: "queue_claim",
1946 engine: "runtime-queue",
1947 result,
1948 affected_rows,
1949 statement_type: "update",
1950 bookmark: None,
1951 notice: None,
1952 })
1953 }
1954}
1955
1956fn validate_queue_claim_shape(query: &UpdateQuery) -> RedDBResult<()> {
1967 if query.order_by.iter().any(|clause| !clause.ascending) {
1968 return Err(RedDBError::InvalidOperation(format!(
1969 "CLAIM on queue '{}' cannot be expressed: a descending ORDER BY \
1970 conflicts with FIFO queue delivery order",
1971 query.table
1972 )));
1973 }
1974 if query.claim_exact {
1975 return Err(RedDBError::InvalidOperation(format!(
1976 "CLAIM EXACT on queue '{}' cannot be expressed: queue delivery \
1977 acquires up to N available messages, not an exact-or-nothing batch",
1978 query.table
1979 )));
1980 }
1981 Ok(())
1982}
1983
1984fn ensure_queue_exists(store: &UnifiedStore, queue: &str) -> RedDBResult<()> {
1985 if store.get_collection(queue).is_some() {
1986 Ok(())
1987 } else {
1988 Err(RedDBError::NotFound(format!("queue '{}' not found", queue)))
1989 }
1990}
1991
1992pub(super) fn load_queue_config(store: &UnifiedStore, queue: &str) -> QueueRuntimeConfig {
1993 let default = QueueRuntimeConfig {
1994 mode: QueueMode::Work,
1995 priority: false,
1996 max_size: None,
1997 ttl_ms: None,
1998 dlq: None,
1999 max_attempts: reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS,
2000 lock_deadline_ms: reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS,
2001 in_flight_cap_per_group: reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP,
2002 retry_delay_ms: None,
2003 dedup_window_ms: None,
2004 };
2005
2006 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2007 return default;
2008 };
2009 manager
2010 .query_all(|entity| {
2011 entity.data.as_row().is_some_and(|row| {
2012 row_text(row, "kind").as_deref() == Some("queue_config")
2013 && row_text(row, "queue").as_deref() == Some(queue)
2014 })
2015 })
2016 .into_iter()
2017 .find_map(|entity| {
2018 let row = entity.data.as_row()?;
2019 Some(QueueRuntimeConfig {
2020 mode: row_text(row, "mode")
2021 .as_deref()
2022 .and_then(QueueMode::parse)
2023 .unwrap_or_default(),
2024 priority: row_bool(row, "priority").unwrap_or(false),
2025 max_size: row_u64(row, "max_size").map(|value| value as usize),
2026 ttl_ms: row_u64(row, "ttl_ms"),
2027 dlq: row_text(row, "dlq"),
2028 max_attempts: row_u64(row, "max_attempts")
2029 .map(|value| value as u32)
2030 .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_MAX_ATTEMPTS),
2031 lock_deadline_ms: row_u64(row, "lock_deadline_ms")
2032 .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_LOCK_DEADLINE_MS),
2033 in_flight_cap_per_group: row_u64(row, "in_flight_cap_per_group")
2034 .map(|value| value as u32)
2035 .unwrap_or(reddb_rql::ast::DEFAULT_QUEUE_IN_FLIGHT_CAP_PER_GROUP),
2036 retry_delay_ms: row_u64(row, "retry_delay_ms").filter(|v| *v > 0),
2037 dedup_window_ms: row_u64(row, "dedup_window_ms"),
2038 })
2039 })
2040 .unwrap_or(default)
2041}
2042
2043pub(super) fn queue_mode_str(store: &UnifiedStore, queue: &str) -> &'static str {
2044 load_queue_config(store, queue).mode.as_str()
2045}
2046
2047fn save_queue_config(
2048 store: &UnifiedStore,
2049 queue: &str,
2050 config: &QueueRuntimeConfig,
2051) -> RedDBResult<()> {
2052 remove_meta_rows(store, |row| {
2053 row_text(row, "kind").as_deref() == Some("queue_config")
2054 && row_text(row, "queue").as_deref() == Some(queue)
2055 });
2056
2057 let mut fields = HashMap::new();
2058 fields.insert("kind".to_string(), Value::text("queue_config".to_string()));
2059 fields.insert("queue".to_string(), Value::text(queue.to_string()));
2060 fields.insert(
2061 "mode".to_string(),
2062 Value::text(config.mode.as_str().to_string()),
2063 );
2064 fields.insert("priority".to_string(), Value::Boolean(config.priority));
2065 fields.insert(
2066 "max_size".to_string(),
2067 config
2068 .max_size
2069 .map(|value| Value::UnsignedInteger(value as u64))
2070 .unwrap_or(Value::Null),
2071 );
2072 fields.insert(
2073 "ttl_ms".to_string(),
2074 config
2075 .ttl_ms
2076 .map(Value::UnsignedInteger)
2077 .unwrap_or(Value::Null),
2078 );
2079 fields.insert(
2080 "dlq".to_string(),
2081 config.dlq.clone().map(Value::text).unwrap_or(Value::Null),
2082 );
2083 fields.insert(
2084 "max_attempts".to_string(),
2085 Value::UnsignedInteger(u64::from(config.max_attempts)),
2086 );
2087 fields.insert(
2088 "lock_deadline_ms".to_string(),
2089 Value::UnsignedInteger(config.lock_deadline_ms),
2090 );
2091 fields.insert(
2092 "in_flight_cap_per_group".to_string(),
2093 Value::UnsignedInteger(u64::from(config.in_flight_cap_per_group)),
2094 );
2095 fields.insert(
2096 "retry_delay_ms".to_string(),
2097 config
2098 .retry_delay_ms
2099 .map(Value::UnsignedInteger)
2100 .unwrap_or(Value::Null),
2101 );
2102 fields.insert(
2103 "dedup_window_ms".to_string(),
2104 config
2105 .dedup_window_ms
2106 .map(Value::UnsignedInteger)
2107 .unwrap_or(Value::Null),
2108 );
2109 insert_meta_row(store, fields).map(|_| ())
2110}
2111
2112fn remove_queue_metadata(store: &UnifiedStore, queue: &str) {
2113 remove_meta_rows(store, |row| {
2114 row_text(row, "queue").as_deref() == Some(queue)
2115 });
2116}
2117
2118fn queue_group_exists(store: &UnifiedStore, queue: &str, group: &str) -> RedDBResult<bool> {
2119 Ok(load_queue_groups(store, queue)?
2120 .into_iter()
2121 .any(|entry| entry.group == group))
2122}
2123
2124pub(super) fn require_queue_group(
2125 store: &UnifiedStore,
2126 queue: &str,
2127 group: &str,
2128) -> RedDBResult<()> {
2129 if queue_group_exists(store, queue, group)? {
2130 Ok(())
2131 } else {
2132 Err(RedDBError::NotFound(format!(
2133 "consumer group '{}' not found on queue '{}'",
2134 group, queue
2135 )))
2136 }
2137}
2138
2139pub(super) fn resolve_read_group(
2140 store: &UnifiedStore,
2141 queue: &str,
2142 group: Option<&str>,
2143 consumer: &str,
2144 config: &QueueRuntimeConfig,
2145) -> RedDBResult<String> {
2146 if let Some(group) = group {
2147 require_queue_group(store, queue, group)?;
2148 return Ok(group.to_string());
2149 }
2150
2151 match config.mode {
2152 QueueMode::Work => {
2153 if !queue_group_exists(store, queue, WORK_DEFAULT_GROUP)? {
2154 save_queue_group(store, queue, WORK_DEFAULT_GROUP)?;
2155 }
2156 Ok(WORK_DEFAULT_GROUP.to_string())
2157 }
2158 QueueMode::Fanout => {
2159 let fanout_group = format!("{FANOUT_GROUP_PREFIX}{consumer}");
2160 if !queue_group_exists(store, queue, &fanout_group)? {
2161 save_queue_group(store, queue, &fanout_group)?;
2162 }
2163 Ok(fanout_group)
2164 }
2165 }
2166}
2167
2168fn load_queue_groups(store: &UnifiedStore, queue: &str) -> RedDBResult<Vec<QueueGroupEntry>> {
2169 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2170 return Ok(Vec::new());
2171 };
2172 Ok(manager
2173 .query_all(|entity| {
2174 entity.data.as_row().is_some_and(|row| {
2175 row_text(row, "kind").as_deref() == Some("queue_group")
2176 && row_text(row, "queue").as_deref() == Some(queue)
2177 })
2178 })
2179 .into_iter()
2180 .filter_map(|entity| {
2181 let row = entity.data.as_row()?;
2182 Some(QueueGroupEntry {
2183 entity_id: entity.id,
2184 group: row_text(row, "group")?,
2185 })
2186 })
2187 .collect())
2188}
2189
2190fn save_queue_group(store: &UnifiedStore, queue: &str, group: &str) -> RedDBResult<()> {
2191 let mut fields = HashMap::new();
2192 fields.insert("kind".to_string(), Value::text("queue_group".to_string()));
2193 fields.insert("queue".to_string(), Value::text(queue.to_string()));
2194 fields.insert("group".to_string(), Value::text(group.to_string()));
2195 fields.insert(
2196 "created_at_ns".to_string(),
2197 Value::UnsignedInteger(now_ns()),
2198 );
2199 insert_meta_row(store, fields).map(|_| ())
2200}
2201
2202pub(super) fn load_pending_entries(
2203 store: &UnifiedStore,
2204 queue: &str,
2205 group: Option<&str>,
2206 message_id: Option<EntityId>,
2207) -> RedDBResult<Vec<QueuePendingEntry>> {
2208 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2209 return Ok(Vec::new());
2210 };
2211 let lock_deadline_ns = load_queue_config(store, queue)
2212 .lock_deadline_ms
2213 .saturating_mul(1_000_000);
2214 let attempts_by_key: HashMap<(String, String, u64), u64> = manager
2215 .query_all(|entity| {
2216 entity.data.as_row().is_some_and(|row| {
2217 row_text(row, "kind").as_deref() == Some("queue_attempts_lc")
2218 && row_text(row, "queue").as_deref() == Some(queue)
2219 })
2220 })
2221 .into_iter()
2222 .filter_map(|entity| {
2223 let row = entity.data.as_row()?;
2224 Some((
2225 (
2226 row_text(row, "queue")?,
2227 row_text(row, "group")?,
2228 row_u64(row, "message_id")?,
2229 ),
2230 row_u64(row, "attempts").unwrap_or(1),
2231 ))
2232 })
2233 .collect();
2234 Ok(manager
2235 .query_all(|entity| {
2236 entity.data.as_row().is_some_and(|row| {
2237 matches!(
2238 row_text(row, "kind").as_deref(),
2239 Some("queue_pending") | Some("queue_pending_lc")
2240 ) && row_text(row, "queue").as_deref() == Some(queue)
2241 && group
2242 .map(|group_name| row_text(row, "group").as_deref() == Some(group_name))
2243 .unwrap_or(true)
2244 && message_id
2245 .map(|candidate| row_u64(row, "message_id") == Some(candidate.raw()))
2246 .unwrap_or(true)
2247 })
2248 })
2249 .into_iter()
2250 .filter_map(|entity| {
2251 let row = entity.data.as_row()?;
2252 let group = row_text(row, "group")?;
2253 let message_id = row_u64(row, "message_id")?;
2254 let kind = row_text(row, "kind")?;
2255 let delivered_at_ns = if kind == "queue_pending_lc" {
2256 row_u64(row, "lock_deadline_ns")
2257 .unwrap_or(0)
2258 .saturating_sub(lock_deadline_ns)
2259 } else {
2260 row_u64(row, "delivered_at_ns")?
2261 };
2262 let delivery_count = if kind == "queue_pending_lc" {
2263 attempts_by_key
2264 .get(&(queue.to_string(), group.clone(), message_id))
2265 .copied()
2266 .unwrap_or(1)
2267 } else {
2268 row_u64(row, "delivery_count").unwrap_or(1)
2269 };
2270 Some(QueuePendingEntry {
2271 entity_id: entity.id,
2272 group,
2273 message_id: EntityId::new(message_id),
2274 consumer: row_text(row, "consumer").unwrap_or_default(),
2275 delivered_at_ns,
2276 delivery_count: delivery_count as u32,
2277 })
2278 })
2279 .collect())
2280}
2281
2282pub(super) fn save_queue_pending(
2283 store: &UnifiedStore,
2284 queue: &str,
2285 group: &str,
2286 message_id: EntityId,
2287 consumer: &str,
2288 delivered_at_ns: u64,
2289 delivery_count: u32,
2290) -> RedDBResult<()> {
2291 remove_meta_rows(store, |row| {
2292 row_text(row, "kind").as_deref() == Some("queue_pending")
2293 && row_text(row, "queue").as_deref() == Some(queue)
2294 && row_text(row, "group").as_deref() == Some(group)
2295 && row_u64(row, "message_id") == Some(message_id.raw())
2296 });
2297
2298 let mut fields = HashMap::new();
2299 fields.insert("kind".to_string(), Value::text("queue_pending".to_string()));
2300 fields.insert("queue".to_string(), Value::text(queue.to_string()));
2301 fields.insert("group".to_string(), Value::text(group.to_string()));
2302 fields.insert(
2303 "message_id".to_string(),
2304 Value::UnsignedInteger(message_id.raw()),
2305 );
2306 fields.insert("consumer".to_string(), Value::text(consumer.to_string()));
2307 fields.insert(
2308 "delivered_at_ns".to_string(),
2309 Value::UnsignedInteger(delivered_at_ns),
2310 );
2311 fields.insert(
2312 "delivery_count".to_string(),
2313 Value::UnsignedInteger(u64::from(delivery_count)),
2314 );
2315 insert_meta_row(store, fields).map(|_| ())
2316}
2317
2318pub(super) fn require_pending_entry(
2319 store: &UnifiedStore,
2320 queue: &str,
2321 group: &str,
2322 message_id: EntityId,
2323) -> RedDBResult<QueuePendingEntry> {
2324 load_pending_entries(store, queue, Some(group), Some(message_id))?
2325 .into_iter()
2326 .next()
2327 .ok_or_else(|| {
2328 RedDBError::NotFound(format!(
2329 "message '{}' is not pending in group '{}' on queue '{}'",
2330 message_id.raw(),
2331 group,
2332 queue
2333 ))
2334 })
2335}
2336
2337pub(super) fn load_ack_entries(
2338 store: &UnifiedStore,
2339 queue: &str,
2340 group: Option<&str>,
2341 message_id: Option<EntityId>,
2342) -> RedDBResult<Vec<QueueAckEntry>> {
2343 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2344 return Ok(Vec::new());
2345 };
2346 Ok(manager
2347 .query_all(|entity| {
2348 entity.data.as_row().is_some_and(|row| {
2349 row_text(row, "kind").as_deref() == Some("queue_ack")
2350 && row_text(row, "queue").as_deref() == Some(queue)
2351 && group
2352 .map(|group_name| row_text(row, "group").as_deref() == Some(group_name))
2353 .unwrap_or(true)
2354 && message_id
2355 .map(|candidate| row_u64(row, "message_id") == Some(candidate.raw()))
2356 .unwrap_or(true)
2357 })
2358 })
2359 .into_iter()
2360 .filter_map(|entity| {
2361 let row = entity.data.as_row()?;
2362 Some(QueueAckEntry {
2363 entity_id: entity.id,
2364 group: row_text(row, "group")?,
2365 message_id: EntityId::new(row_u64(row, "message_id")?),
2366 })
2367 })
2368 .collect())
2369}
2370
2371pub(super) fn save_queue_ack(
2372 store: &UnifiedStore,
2373 queue: &str,
2374 group: &str,
2375 message_id: EntityId,
2376) -> RedDBResult<()> {
2377 let existing = load_ack_entries(store, queue, Some(group), Some(message_id))?;
2378 if !existing.is_empty() {
2379 return Ok(());
2380 }
2381
2382 let mut fields = HashMap::new();
2383 fields.insert("kind".to_string(), Value::text("queue_ack".to_string()));
2384 fields.insert("queue".to_string(), Value::text(queue.to_string()));
2385 fields.insert("group".to_string(), Value::text(group.to_string()));
2386 fields.insert(
2387 "message_id".to_string(),
2388 Value::UnsignedInteger(message_id.raw()),
2389 );
2390 fields.insert("acked_at_ns".to_string(), Value::UnsignedInteger(now_ns()));
2391 insert_meta_row(store, fields).map(|_| ())
2392}
2393
2394pub(super) fn queue_message_completed_for_all_groups(
2395 store: &UnifiedStore,
2396 queue: &str,
2397 message_id: EntityId,
2398) -> RedDBResult<bool> {
2399 let groups = load_queue_groups(store, queue)?;
2400 let pending = load_pending_entries(store, queue, None, Some(message_id))?;
2401 if !pending.is_empty() {
2402 return Ok(false);
2403 }
2404 if groups.is_empty() {
2405 return Ok(true);
2406 }
2407
2408 let acked_groups = load_ack_entries(store, queue, None, Some(message_id))?
2409 .into_iter()
2410 .map(|entry| entry.group)
2411 .collect::<HashSet<_>>();
2412 Ok(groups
2413 .into_iter()
2414 .all(|group| acked_groups.contains(&group.group)))
2415}
2416
2417fn load_queue_message_views(
2418 store: &UnifiedStore,
2419 queue: &str,
2420) -> RedDBResult<Vec<QueueMessageView>> {
2421 load_queue_message_views_with_runtime(None, store, queue)
2422}
2423
2424pub(super) fn load_queue_message_views_with_runtime(
2431 runtime: Option<&RedDBRuntime>,
2432 store: &UnifiedStore,
2433 queue: &str,
2434) -> RedDBResult<Vec<QueueMessageView>> {
2435 let manager = store
2436 .get_collection(queue)
2437 .ok_or_else(|| RedDBError::NotFound(format!("queue '{}' not found", queue)))?;
2438 let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
2442 let rls_filter = runtime.and_then(|rt| {
2443 crate::runtime::impl_core::rls_policy_filter_for_kind(
2444 rt,
2445 queue,
2446 reddb_rql::ast::PolicyAction::Select,
2447 reddb_rql::ast::PolicyTargetKind::Messages,
2448 )
2449 });
2450 let rls_enabled_but_denied = runtime.map(|rt| rt.is_rls_enabled(queue)).unwrap_or(false)
2451 && rls_filter.is_none()
2452 && runtime.is_some();
2453 if rls_enabled_but_denied {
2454 return Ok(Vec::new());
2456 }
2457 let filter_arc = rls_filter.map(std::sync::Arc::new);
2458 let rt_arc = runtime;
2459 Ok(manager
2460 .scan(snap_ctx.as_ref(), move |entity| {
2461 if !matches!(entity.kind, EntityKind::QueueMessage { .. }) {
2462 return false;
2463 }
2464 if let (Some(filter), Some(rt)) = (filter_arc.as_ref(), rt_arc) {
2465 return crate::runtime::query_exec::evaluate_entity_filter_with_db(
2466 Some(&rt.inner.db),
2467 entity,
2468 filter,
2469 queue,
2470 queue,
2471 );
2472 }
2473 true
2474 })
2475 .into_iter()
2476 .filter_map(queue_message_view_from_entity)
2477 .map(|mut view| {
2478 view.available_at_ns = read_message_available_at_ns(store, queue, view.id);
2479 view.ordering_key = read_message_ordering_key(store, queue, view.id);
2480 view
2481 })
2482 .collect())
2483}
2484
2485fn queue_message_view_from_entity(entity: UnifiedEntity) -> Option<QueueMessageView> {
2486 let (position, _) = match &entity.kind {
2487 EntityKind::QueueMessage { position, queue } => (*position, queue),
2488 _ => return None,
2489 };
2490 let data = match entity.data {
2491 EntityData::QueueMessage(data) => data,
2492 _ => return None,
2493 };
2494 Some(QueueMessageView {
2495 id: entity.id,
2496 position,
2497 priority: data.priority.unwrap_or(0),
2498 payload: data.payload,
2499 attempts: data.attempts,
2500 max_attempts: data.max_attempts,
2501 enqueued_at_ns: data.enqueued_at_ns,
2502 available_at_ns: None,
2503 ordering_key: None,
2504 })
2505}
2506
2507pub(super) fn insert_moved_queue_message_payload(
2514 store: &UnifiedStore,
2515 queue: &str,
2516 payload: &Value,
2517) -> RedDBResult<EntityId> {
2518 let config = load_queue_config(store, queue);
2519 let position = next_queue_position(store, queue, QueueSide::Right)?;
2520 let enqueued_at_ns = std::time::SystemTime::now()
2521 .duration_since(std::time::UNIX_EPOCH)
2522 .map(|d| d.as_nanos() as u64)
2523 .unwrap_or(0);
2524 let entity = UnifiedEntity::new(
2525 EntityId::new(0),
2526 EntityKind::QueueMessage {
2527 queue: queue.to_string(),
2528 position,
2529 },
2530 EntityData::QueueMessage(QueueMessageData {
2531 payload: payload.clone(),
2532 priority: None,
2533 enqueued_at_ns,
2534 attempts: 0,
2535 max_attempts: config.max_attempts,
2536 acked: false,
2537 }),
2538 );
2539 let id = store
2540 .insert_auto(queue, entity)
2541 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2542 if let Some(ttl_ms) = config.ttl_ms {
2543 store
2544 .set_metadata(queue, id, queue_message_ttl_metadata(ttl_ms))
2545 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2546 }
2547 Ok(id)
2548}
2549
2550fn insert_moved_queue_message(
2551 store: &UnifiedStore,
2552 queue: &str,
2553 config: &QueueRuntimeConfig,
2554 message: &QueueMessageView,
2555) -> RedDBResult<EntityId> {
2556 let position = next_queue_position(store, queue, QueueSide::Right)?;
2557 let entity = UnifiedEntity::new(
2558 EntityId::new(0),
2559 EntityKind::QueueMessage {
2560 queue: queue.to_string(),
2561 position,
2562 },
2563 EntityData::QueueMessage(QueueMessageData {
2564 payload: message.payload.clone(),
2565 priority: if config.priority {
2566 Some(message.priority)
2567 } else {
2568 None
2569 },
2570 enqueued_at_ns: message.enqueued_at_ns,
2571 attempts: 0,
2572 max_attempts: config.max_attempts,
2573 acked: false,
2574 }),
2575 );
2576 let id = store
2577 .insert_auto(queue, entity)
2578 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2579 if config.ttl_ms.is_some() || message.ordering_key.is_some() {
2580 store
2581 .set_metadata(
2582 queue,
2583 id,
2584 queue_message_metadata(config.ttl_ms, None, message.ordering_key.as_deref()),
2585 )
2586 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2587 }
2588 Ok(id)
2589}
2590
2591fn queue_projection_default_columns() -> Vec<String> {
2592 [
2593 "id",
2594 "payload",
2595 "priority",
2596 "attempts",
2597 "last_error",
2598 "enqueued_at",
2599 "available_at",
2600 "key",
2601 "dlq",
2602 "tenant",
2603 ]
2604 .into_iter()
2605 .map(str::to_string)
2606 .collect()
2607}
2608
2609fn queue_projection_record(
2610 columns: &[String],
2611 message: &QueueMessageView,
2612 dlq: bool,
2613) -> RedDBResult<UnifiedRecord> {
2614 let mut record = UnifiedRecord::new();
2615 for column in columns {
2616 let value = queue_projection_value(message, dlq, column).ok_or_else(|| {
2617 RedDBError::Query(format!("unknown queue projection column '{}'", column))
2618 })?;
2619 record.set(column, value);
2620 }
2621 Ok(record)
2622}
2623
2624fn queue_projection_value(message: &QueueMessageView, dlq: bool, column: &str) -> Option<Value> {
2625 match column {
2626 "id" => Some(Value::text(message_id_string(message.id))),
2627 "payload" => Some(message.payload.clone()),
2628 "priority" => Some(Value::Integer(i64::from(message.priority))),
2629 "attempts" => Some(Value::UnsignedInteger(u64::from(message.attempts))),
2630 "last_error" => Some(Value::Null),
2631 "enqueued_at" => Some(Value::UnsignedInteger(message.enqueued_at_ns)),
2632 "available_at" => Some(Value::UnsignedInteger(
2633 message.available_at_ns.unwrap_or(message.enqueued_at_ns),
2634 )),
2635 "key" => Some(
2636 message
2637 .ordering_key
2638 .as_ref()
2639 .map(|key| Value::text(key.clone()))
2640 .unwrap_or(Value::Null),
2641 ),
2642 "dlq" => Some(Value::Boolean(dlq)),
2643 "tenant" => queue_message_tenant(&message.payload).or(Some(Value::Null)),
2644 _ => None,
2645 }
2646}
2647
2648fn queue_message_tenant(payload: &Value) -> Option<Value> {
2649 let Value::Json(bytes) = payload else {
2650 return None;
2651 };
2652 let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
2653 json.get("tenant")
2654 .and_then(crate::json::Value::as_str)
2655 .map(|tenant| Value::text(tenant.to_string()))
2656}
2657
2658fn queue_is_dead_letter_target(store: &UnifiedStore, queue: &str) -> bool {
2659 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
2660 return false;
2661 };
2662 !manager
2663 .query_all(|entity| {
2664 entity.data.as_row().is_some_and(|row| {
2665 row_text(row, "kind").as_deref() == Some("queue_config")
2666 && row_text(row, "dlq").as_deref() == Some(queue)
2667 })
2668 })
2669 .is_empty()
2670}
2671
2672fn queue_message_matches_filter(message: &QueueMessageView, dlq: bool, filter: &Filter) -> bool {
2673 match filter {
2674 Filter::Compare { field, op, value } => queue_filter_field_value(message, dlq, field)
2675 .is_some_and(|candidate| queue_compare_values(&candidate, value, *op)),
2676 Filter::CompareFields { left, op, right } => {
2677 match (
2678 queue_filter_field_value(message, dlq, left),
2679 queue_filter_field_value(message, dlq, right),
2680 ) {
2681 (Some(left), Some(right)) => queue_compare_values(&left, &right, *op),
2682 _ => false,
2683 }
2684 }
2685 Filter::And(left, right) => {
2686 queue_message_matches_filter(message, dlq, left)
2687 && queue_message_matches_filter(message, dlq, right)
2688 }
2689 Filter::Or(left, right) => {
2690 queue_message_matches_filter(message, dlq, left)
2691 || queue_message_matches_filter(message, dlq, right)
2692 }
2693 Filter::Not(inner) => !queue_message_matches_filter(message, dlq, inner),
2694 Filter::IsNull(field) => queue_filter_field_value(message, dlq, field)
2695 .is_none_or(|value| matches!(value, Value::Null)),
2696 Filter::IsNotNull(field) => queue_filter_field_value(message, dlq, field)
2697 .is_some_and(|value| !matches!(value, Value::Null)),
2698 Filter::In { field, values } => {
2699 queue_filter_field_value(message, dlq, field).is_some_and(|candidate| {
2700 values
2701 .iter()
2702 .any(|value| queue_values_equal(&candidate, value))
2703 })
2704 }
2705 Filter::Between { field, low, high } => queue_filter_field_value(message, dlq, field)
2706 .is_some_and(|candidate| {
2707 queue_compare_values(&candidate, low, CompareOp::Ge)
2708 && queue_compare_values(&candidate, high, CompareOp::Le)
2709 }),
2710 Filter::Like { field, pattern } => queue_filter_text(message, dlq, field)
2711 .is_some_and(|value| queue_like_matches(&value, pattern)),
2712 Filter::StartsWith { field, prefix } => {
2713 queue_filter_text(message, dlq, field).is_some_and(|value| value.starts_with(prefix))
2714 }
2715 Filter::EndsWith { field, suffix } => {
2716 queue_filter_text(message, dlq, field).is_some_and(|value| value.ends_with(suffix))
2717 }
2718 Filter::Contains { field, substring } => {
2719 queue_filter_text(message, dlq, field).is_some_and(|value| value.contains(substring))
2720 }
2721 Filter::CompareExpr { .. } => false,
2722 }
2723}
2724
2725fn queue_filter_field_value(
2726 message: &QueueMessageView,
2727 dlq: bool,
2728 field: &FieldRef,
2729) -> Option<Value> {
2730 match field {
2731 FieldRef::TableColumn { table, column } if table.is_empty() => {
2732 queue_projection_value(message, dlq, column)
2733 .or_else(|| queue_payload_field_value(&message.payload, column))
2734 }
2735 FieldRef::TableColumn { column, .. } => queue_projection_value(message, dlq, column)
2736 .or_else(|| queue_payload_field_value(&message.payload, column)),
2737 _ => None,
2738 }
2739}
2740
2741fn queue_payload_field_value(payload: &Value, field: &str) -> Option<Value> {
2742 let Value::Json(bytes) = payload else {
2743 return None;
2744 };
2745 let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
2746 let value = json.get(field)?;
2747 json_value_to_schema_value(value)
2748}
2749
2750fn json_value_to_schema_value(value: &crate::json::Value) -> Option<Value> {
2751 if matches!(value, crate::json::Value::Null) {
2752 Some(Value::Null)
2753 } else if let Some(value) = value.as_bool() {
2754 Some(Value::Boolean(value))
2755 } else if let Some(value) = value.as_i64() {
2756 Some(Value::Integer(value))
2757 } else if let Some(value) = value.as_u64() {
2758 Some(Value::UnsignedInteger(value))
2759 } else if let Some(value) = value.as_f64() {
2760 Some(Value::Float(value))
2761 } else if let Some(value) = value.as_str() {
2762 Some(Value::text(value.to_string()))
2763 } else {
2764 Some(Value::Json(value.to_string_compact().into_bytes()))
2765 }
2766}
2767
2768fn queue_filter_text(message: &QueueMessageView, dlq: bool, field: &FieldRef) -> Option<String> {
2769 queue_filter_field_value(message, dlq, field).and_then(|value| match value {
2770 Value::Text(value) => Some(value.to_string()),
2771 Value::NodeRef(value) | Value::EdgeRef(value) | Value::TableRef(value) => Some(value),
2772 Value::Integer(value) => Some(value.to_string()),
2773 Value::UnsignedInteger(value) => Some(value.to_string()),
2774 Value::Float(value) => Some(value.to_string()),
2775 Value::Boolean(value) => Some(value.to_string()),
2776 _ => None,
2777 })
2778}
2779
2780fn queue_compare_values(left: &Value, right: &Value, op: CompareOp) -> bool {
2781 match op {
2782 CompareOp::Eq => queue_values_equal(left, right),
2783 CompareOp::Ne => !queue_values_equal(left, right),
2784 CompareOp::Lt => queue_partial_cmp(left, right).is_some_and(|ord| ord.is_lt()),
2785 CompareOp::Le => queue_partial_cmp(left, right).is_some_and(|ord| !ord.is_gt()),
2786 CompareOp::Gt => queue_partial_cmp(left, right).is_some_and(|ord| ord.is_gt()),
2787 CompareOp::Ge => queue_partial_cmp(left, right).is_some_and(|ord| !ord.is_lt()),
2788 }
2789}
2790
2791fn queue_values_equal(left: &Value, right: &Value) -> bool {
2792 if let (Some(left), Some(right)) = (queue_value_number(left), queue_value_number(right)) {
2793 return (left - right).abs() < f64::EPSILON;
2794 }
2795 match (left, right) {
2796 (Value::Text(left), Value::Text(right)) => left == right,
2797 (Value::Boolean(left), Value::Boolean(right)) => left == right,
2798 _ => left == right,
2799 }
2800}
2801
2802fn queue_partial_cmp(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
2803 if let (Some(left), Some(right)) = (queue_value_number(left), queue_value_number(right)) {
2804 return left.partial_cmp(&right);
2805 }
2806 match (left, right) {
2807 (Value::Text(left), Value::Text(right)) => Some(left.cmp(right)),
2808 _ => None,
2809 }
2810}
2811
2812fn queue_value_number(value: &Value) -> Option<f64> {
2813 match value {
2814 Value::Integer(value) => Some(*value as f64),
2815 Value::UnsignedInteger(value) => Some(*value as f64),
2816 Value::Float(value) => Some(*value),
2817 Value::Text(value) => value.parse().ok(),
2818 _ => None,
2819 }
2820}
2821
2822fn queue_like_matches(value: &str, pattern: &str) -> bool {
2823 if pattern == "%" {
2824 return true;
2825 }
2826 let starts_wild = pattern.starts_with('%');
2827 let ends_wild = pattern.ends_with('%');
2828 let needle = pattern.trim_matches('%');
2829 match (starts_wild, ends_wild) {
2830 (true, true) => value.contains(needle),
2831 (true, false) => value.ends_with(needle),
2832 (false, true) => value.starts_with(needle),
2833 (false, false) => value == needle,
2834 }
2835}
2836
2837pub(super) fn queue_message_view_by_id(
2838 store: &UnifiedStore,
2839 queue: &str,
2840 message_id: EntityId,
2841) -> RedDBResult<Option<QueueMessageView>> {
2842 let manager = queue_manager(store, queue)?;
2843 Ok(manager
2844 .get(message_id)
2845 .and_then(queue_message_view_from_entity)
2846 .map(|mut view| {
2847 view.available_at_ns = read_message_available_at_ns(store, queue, view.id);
2848 view
2849 }))
2850}
2851
2852pub(super) fn sort_queue_messages(
2853 messages: &mut [QueueMessageView],
2854 config: &QueueRuntimeConfig,
2855 side: QueueSide,
2856) {
2857 messages.sort_by(|left, right| {
2858 if config.priority {
2859 right
2860 .priority
2861 .cmp(&left.priority)
2862 .then_with(|| match side {
2863 QueueSide::Left => left.position.cmp(&right.position),
2864 QueueSide::Right => right.position.cmp(&left.position),
2865 })
2866 .then_with(|| left.id.raw().cmp(&right.id.raw()))
2867 } else {
2868 match side {
2869 QueueSide::Left => left.position.cmp(&right.position),
2870 QueueSide::Right => right.position.cmp(&left.position),
2871 }
2872 .then_with(|| left.id.raw().cmp(&right.id.raw()))
2873 }
2874 });
2875}
2876
2877pub(super) fn next_queue_position(
2878 store: &UnifiedStore,
2879 queue: &str,
2880 side: QueueSide,
2881) -> RedDBResult<u64> {
2882 let messages = load_queue_message_views(store, queue)?;
2883 if messages.is_empty() {
2884 return Ok(QUEUE_POSITION_CENTER);
2885 }
2886 match side {
2887 QueueSide::Left => Ok(messages
2888 .iter()
2889 .map(|message| message.position)
2890 .min()
2891 .unwrap_or(QUEUE_POSITION_CENTER)
2892 .saturating_sub(1)),
2893 QueueSide::Right => Ok(messages
2894 .iter()
2895 .map(|message| message.position)
2896 .max()
2897 .unwrap_or(QUEUE_POSITION_CENTER)
2898 .saturating_add(1)),
2899 }
2900}
2901
2902pub(super) fn increment_queue_attempts(
2903 store: &UnifiedStore,
2904 queue: &str,
2905 message_id: EntityId,
2906) -> RedDBResult<u32> {
2907 let manager = queue_manager(store, queue)?;
2908 let mut entity = manager
2909 .get(message_id)
2910 .ok_or_else(|| RedDBError::NotFound(format!("message '{}' not found", message_id.raw())))?;
2911 match &mut entity.data {
2912 EntityData::QueueMessage(message) => {
2913 message.attempts = message.attempts.saturating_add(1);
2914 let attempts = message.attempts;
2915 manager
2916 .update(entity)
2917 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2918 Ok(attempts)
2919 }
2920 _ => Err(RedDBError::Query(format!(
2921 "entity '{}' is not a queue message",
2922 message_id.raw()
2923 ))),
2924 }
2925}
2926
2927pub(super) fn queue_message_attempts(
2928 store: &UnifiedStore,
2929 queue: &str,
2930 message_id: EntityId,
2931) -> RedDBResult<u32> {
2932 Ok(queue_message_data(store, queue, message_id)?.attempts)
2933}
2934
2935pub(super) fn queue_message_max_attempts(
2936 store: &UnifiedStore,
2937 queue: &str,
2938 message_id: EntityId,
2939) -> RedDBResult<u32> {
2940 Ok(queue_message_data(store, queue, message_id)?.max_attempts)
2941}
2942
2943pub(super) fn queue_message_payload(
2944 store: &UnifiedStore,
2945 queue: &str,
2946 message_id: EntityId,
2947) -> RedDBResult<Value> {
2948 Ok(queue_message_data(store, queue, message_id)?.payload)
2949}
2950
2951pub(super) fn queue_message_pending_any(
2952 store: &UnifiedStore,
2953 queue: &str,
2954 message_id: EntityId,
2955) -> RedDBResult<bool> {
2956 Ok(!load_pending_entries(store, queue, None, Some(message_id))?.is_empty())
2957}
2958
2959pub(super) fn queue_message_pending_for_group(
2960 store: &UnifiedStore,
2961 queue: &str,
2962 group: &str,
2963 message_id: EntityId,
2964) -> RedDBResult<bool> {
2965 Ok(!load_pending_entries(store, queue, Some(group), Some(message_id))?.is_empty())
2966}
2967
2968pub(super) fn queue_message_acked_for_group(
2969 store: &UnifiedStore,
2970 queue: &str,
2971 group: &str,
2972 message_id: EntityId,
2973) -> RedDBResult<bool> {
2974 Ok(!load_ack_entries(store, queue, Some(group), Some(message_id))?.is_empty())
2975}
2976
2977fn queue_manager(
2978 store: &UnifiedStore,
2979 queue: &str,
2980) -> RedDBResult<Arc<crate::storage::unified::SegmentManager>> {
2981 store
2982 .get_collection(queue)
2983 .ok_or_else(|| RedDBError::NotFound(format!("queue '{}' not found", queue)))
2984}
2985
2986pub(super) fn queue_message_data(
2987 store: &UnifiedStore,
2988 queue: &str,
2989 message_id: EntityId,
2990) -> RedDBResult<QueueMessageData> {
2991 let manager = queue_manager(store, queue)?;
2992 let entity = manager
2993 .get(message_id)
2994 .ok_or_else(|| RedDBError::NotFound(format!("message '{}' not found", message_id.raw())))?;
2995 match entity.data {
2996 EntityData::QueueMessage(message) => Ok(message),
2997 _ => Err(RedDBError::Query(format!(
2998 "entity '{}' is not a queue message",
2999 message_id.raw()
3000 ))),
3001 }
3002}
3003
3004fn insert_meta_row(store: &UnifiedStore, fields: HashMap<String, Value>) -> RedDBResult<EntityId> {
3005 let _ = store.get_or_create_collection(QUEUE_META_COLLECTION);
3006 let id = store
3007 .insert_auto(
3008 QUEUE_META_COLLECTION,
3009 UnifiedEntity::new(
3010 EntityId::new(0),
3011 EntityKind::TableRow {
3012 table: Arc::from(QUEUE_META_COLLECTION),
3013 row_id: 0,
3014 },
3015 EntityData::Row(RowData {
3016 columns: Vec::new(),
3017 named: Some(fields),
3018 schema: None,
3019 }),
3020 ),
3021 )
3022 .map_err(|err| RedDBError::Internal(err.to_string()))?;
3023 Ok(id)
3024}
3025
3026pub(super) fn remove_meta_rows(store: &UnifiedStore, predicate: impl Fn(&RowData) -> bool + Sync) {
3027 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3028 return;
3029 };
3030 let rows = manager.query_all(|entity| entity.data.as_row().is_some_and(&predicate));
3031 for row in rows {
3032 let _ = store.delete(QUEUE_META_COLLECTION, row.id);
3033 }
3034}
3035
3036pub(super) fn reclaim_expired_queue_dedup(
3037 store: &UnifiedStore,
3038 queue: &str,
3039 now_ns: u64,
3040) -> RedDBResult<usize> {
3041 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3042 return Ok(0);
3043 };
3044 let queue = queue.to_string();
3045 let rows = manager.query_all(|entity| {
3046 entity.data.as_row().is_some_and(|row| {
3047 row_text(row, "kind").as_deref() == Some(KIND_QUEUE_PUSH_DEDUP)
3048 && row_text(row, "queue").as_deref() == Some(&queue)
3049 && row_u64(row, "expires_at_ns")
3050 .map(|expires_at| expires_at <= now_ns)
3051 .unwrap_or(true)
3052 })
3053 });
3054 let count = rows.len();
3055 for row in rows {
3056 store
3057 .delete(QUEUE_META_COLLECTION, row.id)
3058 .map_err(|err| RedDBError::Internal(err.to_string()))?;
3059 }
3060 Ok(count)
3061}
3062
3063pub(super) fn find_queue_dedup(
3064 store: &UnifiedStore,
3065 queue: &str,
3066 dedup_key: &str,
3067 now_ns: u64,
3068) -> Option<EntityId> {
3069 let manager = store.get_collection(QUEUE_META_COLLECTION)?;
3070 let queue = queue.to_string();
3071 let dedup_key = dedup_key.to_string();
3072 let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
3073 manager
3074 .scan(snap_ctx.as_ref(), |entity| {
3075 entity.data.as_row().is_some_and(|row| {
3076 row_text(row, "kind").as_deref() == Some(KIND_QUEUE_PUSH_DEDUP)
3077 && row_text(row, "queue").as_deref() == Some(&queue)
3078 && row_text(row, "dedup_key").as_deref() == Some(&dedup_key)
3079 && row_u64(row, "expires_at_ns")
3080 .map(|expires_at| expires_at > now_ns)
3081 .unwrap_or(false)
3082 })
3083 })
3084 .into_iter()
3085 .filter_map(|entity| {
3086 let row = entity.data.as_row()?;
3087 row_u64(row, "message_id").map(EntityId::new)
3088 })
3089 .next()
3090}
3091
3092pub(super) fn record_queue_dedup(
3093 store: &UnifiedStore,
3094 queue: &str,
3095 dedup_key: &str,
3096 message_id: EntityId,
3097 expires_at_ns: u64,
3098) -> RedDBResult<EntityId> {
3099 let mut fields = HashMap::new();
3100 fields.insert(
3101 "kind".to_string(),
3102 Value::text(KIND_QUEUE_PUSH_DEDUP.to_string()),
3103 );
3104 fields.insert("queue".to_string(), Value::text(queue.to_string()));
3105 fields.insert("dedup_key".to_string(), Value::text(dedup_key.to_string()));
3106 fields.insert(
3107 "message_id".to_string(),
3108 Value::UnsignedInteger(message_id.raw()),
3109 );
3110 fields.insert(
3111 "expires_at_ns".to_string(),
3112 Value::UnsignedInteger(expires_at_ns),
3113 );
3114 insert_meta_row(store, fields)
3115}
3116
3117pub(super) fn delete_meta_entity(store: &UnifiedStore, entity_id: EntityId) {
3118 let _ = store.delete(QUEUE_META_COLLECTION, entity_id);
3119}
3120
3121fn queue_message_lock_key(queue: &str, message_id: EntityId) -> String {
3122 format!("{queue}:{}", message_id.raw())
3123}
3124
3125pub(super) fn queue_message_lock_handle(
3126 runtime: &RedDBRuntime,
3127 queue: &str,
3128 message_id: EntityId,
3129) -> Arc<parking_lot::Mutex<()>> {
3130 let key = queue_message_lock_key(queue, message_id);
3131 if let Some(lock) = runtime.inner.queue_message_locks.read().get(&key).cloned() {
3132 return lock;
3133 }
3134
3135 let mut locks = runtime.inner.queue_message_locks.write();
3136 locks
3137 .entry(key)
3138 .or_insert_with(|| Arc::new(parking_lot::Mutex::new(())))
3139 .clone()
3140}
3141
3142pub(super) fn forget_queue_message_lock(runtime: &RedDBRuntime, queue: &str, message_id: EntityId) {
3143 runtime
3144 .inner
3145 .queue_message_locks
3146 .write()
3147 .remove(&queue_message_lock_key(queue, message_id));
3148}
3149
3150fn parse_message_id(value: &str) -> RedDBResult<EntityId> {
3151 let raw = value.strip_prefix('e').unwrap_or(value);
3152 raw.parse::<u64>()
3153 .map(EntityId::new)
3154 .map_err(|_| RedDBError::Query(format!("invalid message id '{}'", value)))
3155}
3156
3157pub(super) fn resolve_ack_nack_handle(
3163 store: &UnifiedStore,
3164 queue: &str,
3165 group_hint: &str,
3166 message_id_hint: &str,
3167 delivery_id: Option<&str>,
3168) -> RedDBResult<(String, EntityId)> {
3169 if let Some(did) = delivery_id {
3170 return resolve_delivery_id(store, queue, did);
3171 }
3172 if group_hint.is_empty() || message_id_hint.is_empty() {
3173 return Err(RedDBError::Query(
3174 "ACK/NACK requires either GROUP <group> '<message_id>' or WITH delivery_id = '<id>'"
3175 .to_string(),
3176 ));
3177 }
3178 log_tuple_deprecation(queue);
3179 let entity = parse_message_id(message_id_hint)?;
3180 Ok((group_hint.to_string(), entity))
3181}
3182
3183fn resolve_delivery_id(
3184 store: &UnifiedStore,
3185 queue: &str,
3186 delivery_id: &str,
3187) -> RedDBResult<(String, EntityId)> {
3188 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3189 return Err(RedDBError::Query(format!(
3190 "delivery_id '{}' does not resolve to a live pending delivery",
3191 delivery_id
3192 )));
3193 };
3194 for entity in manager.query_all(|entity| {
3195 entity.data.as_row().is_some_and(|row| {
3196 row_text(row, "kind").as_deref() == Some("queue_pending_lc")
3197 && row_text(row, "delivery_id").as_deref() == Some(delivery_id)
3198 })
3199 }) {
3200 if let Some(row) = entity.data.as_row() {
3201 let row_queue = row_text(row, "queue").unwrap_or_default();
3202 let row_group = row_text(row, "group").unwrap_or_default();
3203 let row_message = row_u64(row, "message_id").unwrap_or(0);
3204 if row_queue != queue {
3205 return Err(RedDBError::Query(format!(
3206 "delivery_id '{}' belongs to queue '{}', not '{}'",
3207 delivery_id, row_queue, queue
3208 )));
3209 }
3210 return Ok((row_group, EntityId::new(row_message)));
3211 }
3212 }
3213 Err(RedDBError::Query(format!(
3214 "delivery_id '{}' does not resolve to a live pending delivery",
3215 delivery_id
3216 )))
3217}
3218
3219fn log_tuple_deprecation(queue: &str) {
3222 use std::sync::atomic::Ordering;
3223 use std::sync::{Mutex, OnceLock};
3224 use std::time::Instant;
3225
3226 static LAST_EMIT: OnceLock<Mutex<HashMap<(u64, String), Instant>>> = OnceLock::new();
3227 const COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60);
3228
3229 let map = LAST_EMIT.get_or_init(|| Mutex::new(HashMap::new()));
3230 let key = (super::impl_core::current_connection_id(), queue.to_string());
3231 let now = Instant::now();
3232 let mut guard = match map.lock() {
3233 Ok(g) => g,
3234 Err(_) => return,
3235 };
3236 let should_emit =
3237 !matches!(guard.get(&key), Some(prev) if now.duration_since(*prev) < COOLDOWN);
3238 if should_emit {
3239 guard.insert(key.clone(), now);
3240 drop(guard);
3241 TUPLE_DEPRECATION_EMITS.fetch_add(1, Ordering::Relaxed);
3242 tracing::warn!(
3243 target: "reddb::queue_lifecycle",
3244 queue = queue,
3245 connection_id = key.0,
3246 "ACK/NACK by (queue, group, message_id) tuple is deprecated; \
3247 switch to the server-issued delivery_id (ADR 0026). \
3248 The tuple path will be removed one minor release after introduction.",
3249 );
3250 }
3251}
3252
3253pub static TUPLE_DEPRECATION_EMITS: std::sync::atomic::AtomicU64 =
3259 std::sync::atomic::AtomicU64::new(0);
3260
3261fn message_id_string(message_id: EntityId) -> String {
3262 message_id.raw().to_string()
3263}
3264
3265fn delivered_message_json(
3271 message: crate::runtime::queue_lifecycle::DeliveredMessage,
3272) -> crate::serde_json::Value {
3273 use crate::serde_json::{Map, Value as JsonValue};
3274 let mut obj = Map::new();
3275 obj.insert(
3276 "message_id".to_string(),
3277 JsonValue::String(message_id_string(EntityId::new(message.message_id))),
3278 );
3279 obj.insert("payload".to_string(), message.payload.to_json());
3280 obj.insert("consumer".to_string(), JsonValue::String(message.consumer));
3281 obj.insert(
3282 "delivery_count".to_string(),
3283 JsonValue::Number(message.delivery_count as f64),
3284 );
3285 JsonValue::Object(obj)
3286}
3287
3288pub(crate) fn pending_counts_by_group(
3293 store: &UnifiedStore,
3294) -> std::collections::BTreeMap<(String, String), u64> {
3295 let mut counts: std::collections::BTreeMap<(String, String), u64> =
3296 std::collections::BTreeMap::new();
3297 let Some(manager) = store.get_collection(QUEUE_META_COLLECTION) else {
3298 return counts;
3299 };
3300 for entity in manager.query_all(|entity| {
3301 entity
3302 .data
3303 .as_row()
3304 .is_some_and(|row| row_text(row, "kind").as_deref() == Some("queue_pending"))
3305 }) {
3306 if let Some(row) = entity.data.as_row() {
3307 let queue = row_text(row, "queue");
3308 let group = row_text(row, "group");
3309 if let (Some(q), Some(g)) = (queue, group) {
3310 *counts.entry((q, g)).or_insert(0) += 1;
3311 }
3312 }
3313 }
3314 counts
3315}
3316
3317pub(super) fn row_text(row: &RowData, field: &str) -> Option<String> {
3318 match row.get_field(field)?.clone() {
3319 Value::Text(value) => Some(value.to_string()),
3320 Value::NodeRef(value) => Some(value),
3321 Value::EdgeRef(value) => Some(value),
3322 Value::TableRef(value) => Some(value),
3323 _ => None,
3324 }
3325}
3326
3327pub(super) fn row_u64(row: &RowData, field: &str) -> Option<u64> {
3328 match row.get_field(field)?.clone() {
3329 Value::UnsignedInteger(value) => Some(value),
3330 Value::Integer(value) if value >= 0 => Some(value as u64),
3331 Value::Float(value) if value >= 0.0 => Some(value as u64),
3332 Value::Text(value) => value.parse().ok(),
3333 _ => None,
3334 }
3335}
3336
3337fn row_bool(row: &RowData, field: &str) -> Option<bool> {
3338 match row.get_field(field)?.clone() {
3339 Value::Boolean(value) => Some(value),
3340 Value::Text(value) => match value.to_ascii_lowercase().as_str() {
3341 "true" => Some(true),
3342 "false" => Some(false),
3343 _ => None,
3344 },
3345 _ => None,
3346 }
3347}
3348
3349fn queue_collection_contract(
3350 name: &str,
3351 priority: bool,
3352 ttl_ms: Option<u64>,
3353) -> crate::physical::CollectionContract {
3354 let now = current_unix_ms();
3355 let mut context_index_fields = Vec::new();
3356 if priority {
3357 context_index_fields.push("priority".to_string());
3358 }
3359
3360 crate::physical::CollectionContract {
3361 name: name.to_string(),
3362 declared_model: crate::catalog::CollectionModel::Queue,
3363 schema_mode: crate::catalog::SchemaMode::Dynamic,
3364 origin: crate::physical::ContractOrigin::Explicit,
3365 version: 1,
3366 created_at_unix_ms: now,
3367 updated_at_unix_ms: now,
3368 default_ttl_ms: ttl_ms,
3369 vector_dimension: None,
3370 vector_metric: None,
3371 context_index_fields,
3372 declared_columns: Vec::new(),
3373 table_def: None,
3374 timestamps_enabled: false,
3375 context_index_enabled: false,
3376 metrics_raw_retention_ms: None,
3377 metrics_rollup_policies: Vec::new(),
3378 metrics_tenant_identity: None,
3379 metrics_namespace: None,
3380 append_only: true,
3384 subscriptions: Vec::new(),
3385 analytics_config: Vec::new(),
3386 session_key: None,
3387 session_gap_ms: None,
3388 retention_duration_ms: None,
3389 analytical_storage: None,
3390
3391 ai_policy: None,
3392 }
3393}
3394
3395fn current_unix_ms() -> u128 {
3396 std::time::SystemTime::now()
3397 .duration_since(std::time::UNIX_EPOCH)
3398 .unwrap_or_default()
3399 .as_millis()
3400}
3401
3402pub(super) fn now_ns() -> u64 {
3403 std::time::SystemTime::now()
3404 .duration_since(std::time::UNIX_EPOCH)
3405 .unwrap_or_default()
3406 .as_nanos() as u64
3407}
3408
3409pub(super) fn queue_message_ttl_metadata(ttl_ms: u64) -> Metadata {
3410 queue_message_metadata(Some(ttl_ms), None, None)
3411}
3412
3413pub(super) fn queue_message_metadata(
3419 ttl_ms: Option<u64>,
3420 available_at_ns: Option<u64>,
3421 ordering_key: Option<&str>,
3422) -> Metadata {
3423 let mut fields = HashMap::new();
3424 if let Some(ttl_ms) = ttl_ms {
3425 fields.insert(
3426 "_ttl_ms".to_string(),
3427 if ttl_ms <= i64::MAX as u64 {
3428 MetadataValue::Int(ttl_ms as i64)
3429 } else {
3430 MetadataValue::Timestamp(ttl_ms)
3431 },
3432 );
3433 }
3434 if let Some(at_ns) = available_at_ns {
3435 fields.insert(
3436 "_available_at_ns".to_string(),
3437 MetadataValue::Timestamp(at_ns),
3438 );
3439 }
3440 if let Some(key) = ordering_key {
3441 fields.insert(
3442 "_ordering_key".to_string(),
3443 MetadataValue::String(key.to_string()),
3444 );
3445 }
3446 Metadata::with_fields(fields)
3447}
3448
3449pub(super) fn earliest_future_available_at(store: &UnifiedStore, queue: &str) -> Option<u64> {
3457 let now_ns = now_ns();
3458 let views = load_queue_message_views_with_runtime(None, store, queue).ok()?;
3459 views
3460 .iter()
3461 .filter_map(|v| v.available_at_ns)
3462 .filter(|at| *at > now_ns)
3463 .min()
3464}
3465
3466pub(super) fn set_message_available_at_ns(
3477 store: &UnifiedStore,
3478 queue: &str,
3479 message_id: EntityId,
3480 available_at_ns: Option<u64>,
3481 fallback_ttl_ms: Option<u64>,
3482) -> RedDBResult<()> {
3483 let existing_ttl_ms = store
3484 .get_metadata(queue, message_id)
3485 .and_then(|md| match md.get("_ttl_ms")? {
3486 MetadataValue::Int(i) if *i >= 0 => Some(*i as u64),
3487 MetadataValue::Timestamp(t) => Some(*t),
3488 _ => None,
3489 })
3490 .or(fallback_ttl_ms);
3491 let existing_ordering_key = read_message_ordering_key(store, queue, message_id);
3492 let metadata = queue_message_metadata(
3493 existing_ttl_ms,
3494 available_at_ns,
3495 existing_ordering_key.as_deref(),
3496 );
3497 match store.set_metadata(queue, message_id, metadata) {
3498 Ok(()) => Ok(()),
3499 Err(crate::storage::StoreError::CollectionNotFound(_)) => Ok(()),
3500 Err(err) => Err(RedDBError::Internal(err.to_string())),
3501 }
3502}
3503
3504pub(super) fn read_message_ordering_key(
3507 store: &UnifiedStore,
3508 queue: &str,
3509 message_id: EntityId,
3510) -> Option<String> {
3511 let md = store.get_metadata(queue, message_id)?;
3512 match md.get("_ordering_key")? {
3513 MetadataValue::String(value) => Some(value.clone()),
3514 _ => None,
3515 }
3516}
3517
3518pub(super) fn read_message_available_at_ns(
3522 store: &UnifiedStore,
3523 queue: &str,
3524 message_id: EntityId,
3525) -> Option<u64> {
3526 let md = store.get_metadata(queue, message_id)?;
3527 match md.get("_available_at_ns")? {
3528 MetadataValue::Timestamp(t) => Some(*t),
3529 MetadataValue::Int(i) if *i >= 0 => Some(*i as u64),
3530 _ => None,
3531 }
3532}
3533
3534fn estimate_payload_bytes(payload: &Value) -> u64 {
3536 match payload {
3537 Value::Json(v) => v.len() as u64,
3538 Value::Text(s) => s.len() as u64,
3539 _ => 64,
3540 }
3541}
3542
3543#[cfg(test)]
3544mod presence_integration_tests {
3545 use super::*;
3546 use crate::storage::queue::presence::{PresenceState, DEFAULT_PRESENCE_TTL_MS};
3547 use crate::{RedDBOptions, RedDBRuntime};
3548
3549 #[test]
3553 fn queue_read_emits_consumer_presence_heartbeat() {
3554 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3555 rt.execute_query("CREATE QUEUE tasks").unwrap();
3556 rt.execute_query("QUEUE GROUP CREATE tasks workers")
3557 .unwrap();
3558 rt.execute_query("QUEUE PUSH tasks {'job':'a'}").unwrap();
3559 rt.execute_query("QUEUE READ tasks GROUP workers CONSUMER w1")
3560 .unwrap();
3561
3562 let snap = rt.queue_consumer_presence_snapshot(DEFAULT_PRESENCE_TTL_MS);
3563 assert_eq!(snap.len(), 1, "exactly one heartbeat recorded");
3564 let row = &snap[0];
3565 assert_eq!(row.queue, "tasks");
3566 assert_eq!(row.group, "workers");
3567 assert_eq!(row.consumer, "w1");
3568 assert_eq!(row.state, PresenceState::Active);
3569
3570 let counts = rt.queue_active_consumer_counts(DEFAULT_PRESENCE_TTL_MS);
3571 assert_eq!(
3572 counts[&("tasks".to_string(), "workers".to_string())],
3573 1,
3574 "active count reflects the live consumer"
3575 );
3576 }
3577
3578 #[test]
3582 fn empty_queue_read_still_heartbeats() {
3583 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3584 rt.execute_query("CREATE QUEUE empty_q").unwrap();
3585 rt.execute_query("QUEUE GROUP CREATE empty_q workers")
3586 .unwrap();
3587 rt.execute_query("QUEUE READ empty_q GROUP workers CONSUMER w1")
3589 .unwrap();
3590
3591 let snap = rt.queue_consumer_presence_snapshot(DEFAULT_PRESENCE_TTL_MS);
3592 assert_eq!(
3593 snap.len(),
3594 1,
3595 "empty read still registers consumer presence"
3596 );
3597 assert_eq!(snap[0].state, PresenceState::Active);
3598 assert_eq!(
3599 snap[0].lease_count, 0,
3600 "no messages delivered → zero leases"
3601 );
3602 }
3603}