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