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