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