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