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