1use std::collections::{BTreeSet, HashMap, VecDeque};
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
10
11use corium_core::{Datom, IndexOrder, KeywordInterner, Schema};
12use corium_db::{Db, Idents};
13use corium_log::{LogError, TransactionLog, TxRecord};
14use corium_protocol::codec::{self, CodecError};
15use corium_protocol::pb;
16use corium_protocol::schemaform::{SchemaFormError, schema_from_edn};
17use corium_protocol::txforms::{TxFormError, tx_items_from_edn};
18use corium_query::edn::Edn;
19use corium_store::{
20 BlobId, BlobStore, RootStore, StoreError, decode_index_manifest, decode_segment_keys,
21 is_index_manifest, mark_and_sweep_retained, meta_root_name,
22};
23use thiserror::Error;
24use tokio::sync::{broadcast, oneshot, watch};
25use tracing::Instrument;
26
27use crate::backend::{LogBackend, NodeStore, StorageInfoConfig, StoreSpec};
28use crate::lease::{self, Lease, LeaseError};
29use crate::metrics::Metrics;
30use crate::{DbRoot, EmbeddedTransactor, Prepared, TransactError, db_root_name};
31
32pub trait TxFnExpander: Send + Sync {
37 fn expand(&self, db: &Db, forms: Vec<Edn>) -> Result<Vec<Edn>, String>;
44}
45
46#[derive(Clone)]
48pub struct NodeConfig {
49 pub store: StoreSpec,
51 pub storage_info: StorageInfoConfig,
54 pub data_dir: PathBuf,
57 pub owner: String,
59 pub lease_ttl_ms: i64,
61 pub lease_wait_ms: i64,
63 pub ha: bool,
67 pub advertise: Option<String>,
70 pub index_interval: Duration,
72 pub index_backoff: u32,
78 pub index_tail_threshold: u64,
82 pub index_tail_deadline: Duration,
84 pub heartbeat_interval: Duration,
86 pub gc_interval: Option<Duration>,
88 pub gc_retention: Duration,
90 pub max_commit_batch: usize,
96 pub max_commit_batch_bytes: usize,
101 pub tx_fn_expander: Option<Arc<dyn TxFnExpander>>,
103}
104
105impl std::fmt::Debug for NodeConfig {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 f.debug_struct("NodeConfig")
108 .field("store", &self.store)
109 .field("storage_info", &self.storage_info)
110 .field("data_dir", &self.data_dir)
111 .field("owner", &self.owner)
112 .field("lease_ttl_ms", &self.lease_ttl_ms)
113 .field("lease_wait_ms", &self.lease_wait_ms)
114 .field("ha", &self.ha)
115 .field("advertise", &self.advertise)
116 .field("index_interval", &self.index_interval)
117 .field("index_backoff", &self.index_backoff)
118 .field("index_tail_threshold", &self.index_tail_threshold)
119 .field("index_tail_deadline", &self.index_tail_deadline)
120 .field("heartbeat_interval", &self.heartbeat_interval)
121 .field("gc_interval", &self.gc_interval)
122 .field("gc_retention", &self.gc_retention)
123 .field("max_commit_batch", &self.max_commit_batch)
124 .field("max_commit_batch_bytes", &self.max_commit_batch_bytes)
125 .field("tx_fn_expander", &self.tx_fn_expander.is_some())
126 .finish()
127 }
128}
129
130impl NodeConfig {
131 #[must_use]
133 pub fn new(data_dir: PathBuf) -> Self {
134 Self {
135 store: StoreSpec::Fs,
136 storage_info: StorageInfoConfig::default(),
137 data_dir,
138 owner: format!(
139 "transactor-{}",
140 std::env::var("HOSTNAME").unwrap_or_else(|_| "local".into())
141 ),
142 lease_ttl_ms: 5_000,
143 lease_wait_ms: 15_000,
144 ha: false,
145 advertise: None,
146 index_interval: Duration::from_secs(5),
147 index_backoff: 4,
148 index_tail_threshold: 0,
149 index_tail_deadline: Duration::from_secs(60),
150 heartbeat_interval: Duration::from_secs(10),
151 gc_interval: Some(Duration::from_secs(60 * 60)),
152 gc_retention: Duration::from_secs(72 * 60 * 60),
153 max_commit_batch: 256,
154 max_commit_batch_bytes: 4 * 1024 * 1024,
155 #[cfg(feature = "cljrs")]
156 tx_fn_expander: Some(Arc::new(crate::txfn::DbFnExpander::default())),
157 #[cfg(not(feature = "cljrs"))]
158 tx_fn_expander: None,
159 }
160 }
161}
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub struct IndexPolicy {
177 pub interval: Duration,
179 pub backoff: u32,
182 pub tail_threshold: u64,
185 pub tail_deadline: Duration,
188}
189
190#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
192pub struct IndexPolicyUpdate {
193 pub interval: Option<Duration>,
195 pub backoff: Option<u32>,
197 pub tail_threshold: Option<u64>,
199 pub tail_deadline: Option<Duration>,
201}
202
203impl IndexPolicy {
204 fn from_config(config: &NodeConfig) -> Self {
205 Self {
206 interval: config.index_interval,
207 backoff: config.index_backoff,
208 tail_threshold: config.index_tail_threshold,
209 tail_deadline: config.index_tail_deadline,
210 }
211 }
212
213 fn apply(&mut self, update: IndexPolicyUpdate) {
214 if let Some(interval) = update.interval {
215 self.interval = interval;
216 }
217 if let Some(backoff) = update.backoff {
218 self.backoff = backoff;
219 }
220 if let Some(tail_threshold) = update.tail_threshold {
221 self.tail_threshold = tail_threshold;
222 }
223 if let Some(tail_deadline) = update.tail_deadline {
224 self.tail_deadline = tail_deadline;
225 }
226 }
227
228 fn due(&self, since_publish: Duration, last_duration: Duration, pending: Option<u64>) -> bool {
235 let floor = self
236 .interval
237 .max(last_duration.saturating_mul(self.backoff));
238 if since_publish < floor {
239 return false;
240 }
241 match pending {
242 Some(pending) if pending < self.tail_threshold => since_publish >= self.tail_deadline,
243 _ => true,
244 }
245 }
246}
247
248#[derive(Debug, Error)]
250pub enum NodeError {
251 #[error("unknown database {0:?}")]
253 UnknownDb(String),
254 #[error("invalid database name {0:?}")]
256 InvalidName(String),
257 #[error("storage format {found} is newer than supported format {supported}")]
259 UnsupportedFormat {
260 found: u32,
262 supported: u32,
264 },
265 #[error("deposed: write lease for {0:?} is held elsewhere")]
267 Deposed(String),
268 #[error("standby for {db:?}: lease held by {owner} at {endpoint:?}")]
271 Standby {
272 db: String,
274 owner: String,
276 endpoint: String,
278 },
279 #[error(transparent)]
281 Codec(#[from] CodecError),
282 #[error(transparent)]
284 TxForm(#[from] TxFormError),
285 #[error(transparent)]
287 SchemaForm(#[from] SchemaFormError),
288 #[error(transparent)]
290 Transact(#[from] TransactError),
291 #[error(transparent)]
293 Store(#[from] StoreError),
294 #[error(transparent)]
296 Log(#[from] LogError),
297 #[error(transparent)]
299 Lease(#[from] LeaseError),
300 #[error("bad request: {0}")]
302 BadRequest(String),
303 #[error("basis changed: expected {expected}, current basis is {actual}")]
305 BasisMismatch {
306 expected: u64,
308 actual: u64,
310 },
311 #[error("group commit aborted: {0}")]
316 GroupCommit(String),
317}
318
319struct Naming {
320 schema: Schema,
321 idents: Idents,
322 interner: KeywordInterner,
323}
324
325struct CommitRequest {
328 forms: Vec<Edn>,
329 expected_basis_t: Option<u64>,
330 resp: oneshot::Sender<Result<pb::TransactResponse, NodeError>>,
331}
332
333pub struct DbState {
335 name: String,
336 transactor: EmbeddedTransactor,
337 log: Arc<dyn TransactionLog>,
338 naming: Mutex<Naming>,
339 commit: tokio::sync::Mutex<()>,
343 pending: Mutex<VecDeque<CommitRequest>>,
345 broadcast: broadcast::Sender<pb::subscribe_item::Item>,
346 basis: watch::Sender<u64>,
347 index_basis: AtomicU64,
348 index_policy: Mutex<IndexPolicy>,
349 held_lease: Mutex<Lease>,
350 deposed: AtomicBool,
351}
352
353impl DbState {
354 #[must_use]
356 pub fn name(&self) -> &str {
357 &self.name
358 }
359
360 #[must_use]
362 pub fn db(&self) -> Db {
363 self.transactor.db()
364 }
365
366 #[must_use]
368 pub fn basis_watch(&self) -> watch::Receiver<u64> {
369 self.basis.subscribe()
370 }
371
372 #[must_use]
375 pub fn stream_items(&self) -> broadcast::Receiver<pb::subscribe_item::Item> {
376 self.broadcast.subscribe()
377 }
378
379 #[must_use]
381 pub fn index_basis(&self) -> u64 {
382 self.index_basis.load(Ordering::Acquire)
383 }
384
385 #[must_use]
387 pub fn index_policy(&self) -> IndexPolicy {
388 *self
389 .index_policy
390 .lock()
391 .unwrap_or_else(std::sync::PoisonError::into_inner)
392 }
393
394 #[must_use]
396 pub fn lease(&self) -> Lease {
397 self.held_lease
398 .lock()
399 .unwrap_or_else(std::sync::PoisonError::into_inner)
400 .clone()
401 }
402
403 #[must_use]
406 pub fn handshake_snapshot(&self) -> (Vec<u8>, KeywordInterner) {
407 let naming = self
408 .naming
409 .lock()
410 .unwrap_or_else(std::sync::PoisonError::into_inner);
411 (
412 codec::encode_schema(&naming.schema, &naming.idents),
413 naming.interner.clone(),
414 )
415 }
416
417 pub async fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, NodeError> {
422 Ok(self.log.tx_range_async(start, end).await?)
423 }
424
425 async fn check_lease(&self, store: &dyn RootStore) -> Result<Lease, NodeError> {
428 if self.deposed.load(Ordering::Acquire) {
429 return Err(NodeError::Deposed(self.name.clone()));
430 }
431 let held = self.lease();
432 match lease::verify(store, &self.name, &held).await {
433 Ok(()) => Ok(held),
434 Err(LeaseError::Lost) => {
435 self.deposed.store(true, Ordering::Release);
436 Err(NodeError::Deposed(self.name.clone()))
437 }
438 Err(error) => Err(error.into()),
439 }
440 }
441}
442
443pub struct TransactorNode {
445 config: NodeConfig,
446 store: Arc<NodeStore>,
447 log_backend: LogBackend,
448 dbs: std::sync::RwLock<HashMap<String, Arc<DbState>>>,
449 standby: std::sync::RwLock<BTreeSet<String>>,
452 gc_lock: tokio::sync::Mutex<()>,
453 fork_lock: tokio::sync::Mutex<()>,
456 metrics: Metrics,
457 shutdown: watch::Sender<Option<String>>,
458}
459
460fn now_unix_ms() -> i64 {
461 i64::try_from(
462 SystemTime::now()
463 .duration_since(UNIX_EPOCH)
464 .unwrap_or_default()
465 .as_millis(),
466 )
467 .unwrap_or(i64::MAX)
468}
469
470fn batch_abort_error(name: &str, error: &NodeError) -> NodeError {
476 match error {
477 NodeError::Deposed(_) => NodeError::Deposed(name.to_owned()),
478 other => NodeError::GroupCommit(other.to_string()),
479 }
480}
481
482fn valid_db_name(name: &str) -> bool {
483 !name.is_empty()
484 && name.len() <= 128
485 && name
486 .bytes()
487 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
488}
489
490impl TransactorNode {
491 pub async fn open(config: NodeConfig) -> Result<Arc<Self>, NodeError> {
499 #[cfg(feature = "s3")]
500 if matches!(config.store, StoreSpec::S3 { .. }) && config.storage_info.s3.is_none() {
501 tracing::warn!(
502 "S3 read-only credentials are not configured; GetStorageInfo will reject \
503 peer bootstrap and direct-storage backup requests"
504 );
505 }
506 config.storage_info.initialize().await;
507 let store = Arc::new(NodeStore::open(&config.store, &config.data_dir).await?);
508 let log_backend = LogBackend::for_spec(&config.store, &config.data_dir, Arc::clone(&store));
509 let node = Arc::new(Self {
510 config,
511 store,
512 log_backend,
513 dbs: std::sync::RwLock::new(HashMap::new()),
514 standby: std::sync::RwLock::new(BTreeSet::new()),
515 gc_lock: tokio::sync::Mutex::new(()),
516 fork_lock: tokio::sync::Mutex::new(()),
517 metrics: Metrics::default(),
518 shutdown: watch::channel(None).0,
519 });
520 let names: Vec<String> = node
521 .store
522 .list_roots("meta:")
523 .await?
524 .into_iter()
525 .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
526 .collect();
527 for name in names {
528 match node.open_db(&name).await {
529 Ok(state) => {
530 node.dbs
531 .write()
532 .unwrap_or_else(std::sync::PoisonError::into_inner)
533 .insert(name, state);
534 }
535 Err(NodeError::Lease(LeaseError::Held { owner, .. })) if node.config.ha => {
536 tracing::info!(db = %name, %owner, "standing by; lease held elsewhere");
537 node.standby
538 .write()
539 .unwrap_or_else(std::sync::PoisonError::into_inner)
540 .insert(name);
541 }
542 Err(error) => return Err(error),
543 }
544 }
545 node.spawn_standby_poller();
546 node.spawn_scheduled_gc();
547 Ok(node)
548 }
549
550 #[must_use]
552 pub fn store(&self) -> &Arc<NodeStore> {
553 &self.store
554 }
555
556 #[must_use]
558 pub fn config(&self) -> &NodeConfig {
559 &self.config
560 }
561
562 #[must_use]
564 pub const fn metrics(&self) -> &Metrics {
565 &self.metrics
566 }
567
568 fn spawn_scheduled_gc(self: &Arc<Self>) {
569 let Some(interval) = self.config.gc_interval else {
570 return;
571 };
572 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
573 return;
576 };
577 let node = Arc::clone(self);
578 runtime.spawn(async move {
579 let mut ticker = tokio::time::interval(interval);
580 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
581 ticker.tick().await;
583 loop {
584 ticker.tick().await;
585 if let Err(error) = node.gc_deleted().await {
586 tracing::warn!(%error, "scheduled garbage collection failed");
587 }
588 }
589 });
590 }
591
592 #[must_use]
594 pub fn shutdown_watch(&self) -> watch::Receiver<Option<String>> {
595 self.shutdown.subscribe()
596 }
597
598 fn depose(&self, state: &DbState, reason: &str) {
602 state.deposed.store(true, Ordering::Release);
603 if self.config.ha {
604 tracing::warn!(db = %state.name, reason, "deposed; returning to standby");
605 self.dbs
606 .write()
607 .unwrap_or_else(std::sync::PoisonError::into_inner)
608 .remove(&state.name);
609 self.standby
610 .write()
611 .unwrap_or_else(std::sync::PoisonError::into_inner)
612 .insert(state.name.clone());
613 } else {
614 let _ = self
615 .shutdown
616 .send(Some(format!("database {:?}: {reason}", state.name)));
617 }
618 }
619
620 fn advertised(&self) -> &str {
621 self.config.advertise.as_deref().unwrap_or("")
622 }
623
624 async fn acquire_lease(&self, name: &str) -> Result<Lease, NodeError> {
628 let deadline = now_unix_ms() + self.config.lease_wait_ms;
629 loop {
630 match lease::acquire(
631 self.store.as_ref(),
632 name,
633 &self.config.owner,
634 self.advertised(),
635 self.config.lease_ttl_ms,
636 now_unix_ms(),
637 )
638 .await
639 {
640 Ok(held) => return Ok(held),
641 Err(LeaseError::Held { .. }) if !self.config.ha && now_unix_ms() < deadline => {
642 tokio::time::sleep(Duration::from_millis(200)).await;
643 }
644 Err(error) => return Err(error.into()),
645 }
646 }
647 }
648
649 async fn open_db(self: &Arc<Self>, name: &str) -> Result<Arc<DbState>, NodeError> {
650 let meta = self
651 .store
652 .get_root(&meta_root_name(name))
653 .await?
654 .ok_or_else(|| NodeError::UnknownDb(name.to_owned()))?;
655 let (schema, idents, interner) = codec::decode_metadata(&meta)?;
656 let root_name = db_root_name(name);
657 let current = self
658 .store
659 .get_root(&root_name)
660 .await?
661 .as_deref()
662 .and_then(DbRoot::decode);
663 if let Some(root) = ¤t
664 && root.format_version > corium_store::FORMAT_VERSION
665 {
666 return Err(NodeError::UnsupportedFormat {
667 found: root.format_version,
668 supported: corium_store::FORMAT_VERSION,
669 });
670 }
671 let held = self.acquire_lease(name).await?;
677 let log = self.log_backend.open(name, held.version).await?;
680 let post_fence = self
681 .store
682 .get_root(&root_name)
683 .await?
684 .as_deref()
685 .and_then(DbRoot::decode);
686 let transactor = self
687 .recover_transactor(name, &schema, &idents, &interner, post_fence.as_ref(), &log)
688 .await?;
689 let basis_t = transactor.db().basis_t();
690 let index_basis = post_fence.map_or(0, |root| root.index_basis_t);
691 let state = Arc::new(DbState {
692 name: name.to_owned(),
693 transactor,
694 log,
695 naming: Mutex::new(Naming {
696 schema,
697 idents,
698 interner,
699 }),
700 commit: tokio::sync::Mutex::new(()),
701 pending: Mutex::new(VecDeque::new()),
702 broadcast: broadcast::channel(1024).0,
703 basis: watch::channel(basis_t).0,
704 index_basis: AtomicU64::new(index_basis),
705 index_policy: Mutex::new(IndexPolicy::from_config(&self.config)),
706 held_lease: Mutex::new(held),
707 deposed: AtomicBool::new(false),
708 });
709 self.spawn_maintenance(&state);
710 Ok(state)
711 }
712
713 async fn recover_transactor(
722 &self,
723 name: &str,
724 schema: &Schema,
725 idents: &Idents,
726 interner: &KeywordInterner,
727 root: Option<&DbRoot>,
728 log: &Arc<dyn TransactionLog>,
729 ) -> Result<EmbeddedTransactor, NodeError> {
730 if let Some(root) = root
733 && let Some(roots) = &root.roots
734 && root.next_entity_id != 0
735 {
736 match self
737 .load_current_snapshot(
738 root,
739 &roots[IndexOrder::Eavt as usize],
740 schema,
741 idents,
742 interner,
743 )
744 .await
745 {
746 Ok(snapshot) => {
747 return Ok(EmbeddedTransactor::recover_from_snapshot_async(
748 snapshot,
749 root.next_entity_id,
750 root.last_tx_instant,
751 Arc::clone(log),
752 )
753 .await?);
754 }
755 Err(error) => {
756 tracing::warn!(
757 db = %name,
758 %error,
759 "index-root recovery failed; falling back to full-log replay"
760 );
761 }
762 }
763 }
764 let base = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
765 Ok(EmbeddedTransactor::recover_from_async(base, Arc::clone(log)).await?)
766 }
767
768 async fn load_current_snapshot(
773 &self,
774 root: &DbRoot,
775 eavt: &BlobId,
776 schema: &Schema,
777 idents: &Idents,
778 interner: &KeywordInterner,
779 ) -> Result<Db, StoreError> {
780 let datoms = self
781 .load_index_keys(eavt)
782 .await?
783 .into_iter()
784 .map(|key| Datom::from_key(IndexOrder::Eavt, &key))
785 .collect::<Result<Vec<_>, _>>()
786 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
787 Ok(Db::from_current_snapshot(
788 root.index_basis_t,
789 schema.clone(),
790 idents.clone(),
791 interner.clone(),
792 datoms,
793 ))
794 }
795
796 async fn load_index_keys(&self, id: &BlobId) -> Result<Vec<Vec<u8>>, StoreError> {
799 let blob = self
800 .store
801 .get(id)
802 .await?
803 .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
804 if !is_index_manifest(&blob) {
805 return decode_segment_keys(&blob);
806 }
807 let mut keys = Vec::new();
808 for child in decode_index_manifest(&blob)? {
809 let chunk = self
810 .store
811 .get(&child)
812 .await?
813 .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
814 keys.extend(decode_segment_keys(&chunk)?);
815 }
816 Ok(keys)
817 }
818
819 fn spawn_standby_poller(self: &Arc<Self>) {
825 if !self.config.ha {
826 return;
827 }
828 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
829 return;
830 };
831 let ttl = self.config.lease_ttl_ms;
832 let poll_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
833 let node = Arc::clone(self);
834 runtime.spawn(async move {
835 let mut ticker = tokio::time::interval(poll_every);
836 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
837 loop {
838 ticker.tick().await;
839 if let Err(error) = node.standby_scan().await {
840 tracing::warn!(%error, "standby scan failed");
841 }
842 }
843 });
844 }
845
846 async fn standby_scan(self: &Arc<Self>) -> Result<(), NodeError> {
849 let names: Vec<String> = self
850 .store
851 .list_roots("meta:")
852 .await?
853 .into_iter()
854 .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
855 .collect();
856 {
857 let mut standby = self
858 .standby
859 .write()
860 .unwrap_or_else(std::sync::PoisonError::into_inner);
861 standby.retain(|name| names.contains(name));
862 }
863 for name in names {
864 if self
865 .dbs
866 .read()
867 .unwrap_or_else(std::sync::PoisonError::into_inner)
868 .contains_key(&name)
869 {
870 continue;
871 }
872 match self.open_db(&name).await {
873 Ok(state) => {
874 tracing::info!(db = %name, owner = %self.config.owner, "standby took over write lease");
875 self.standby
876 .write()
877 .unwrap_or_else(std::sync::PoisonError::into_inner)
878 .remove(&name);
879 self.dbs
880 .write()
881 .unwrap_or_else(std::sync::PoisonError::into_inner)
882 .insert(name, state);
883 }
884 Err(NodeError::Lease(LeaseError::Held { .. })) => {
885 self.standby
886 .write()
887 .unwrap_or_else(std::sync::PoisonError::into_inner)
888 .insert(name);
889 }
890 Err(error) => {
891 tracing::warn!(db = %name, %error, "standby takeover attempt failed");
892 }
893 }
894 }
895 Ok(())
896 }
897
898 fn spawn_maintenance(self: &Arc<Self>, state: &Arc<DbState>) {
899 let ttl = self.config.lease_ttl_ms;
900 let renew_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
901 let node = Arc::clone(self);
903 let db = Arc::clone(state);
904 tokio::spawn(async move {
905 let mut ticker = tokio::time::interval(renew_every);
906 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
907 loop {
908 ticker.tick().await;
909 if db.deposed.load(Ordering::Acquire) {
910 return;
911 }
912 let _commit = db.commit.lock().await;
916 let held = db.lease();
917 let name = db.name.clone();
918 let renewed =
919 lease::renew(node.store.as_ref(), &name, &held, ttl, now_unix_ms()).await;
920 match renewed {
921 Ok(renewed) => {
922 *db.held_lease
923 .lock()
924 .unwrap_or_else(std::sync::PoisonError::into_inner) = renewed;
925 }
926 Err(LeaseError::Lost) => {
927 node.depose(&db, "write lease lost");
928 return;
929 }
930 Err(_) => {}
931 }
932 }
933 });
934 self.spawn_indexing(state);
935 let node = Arc::clone(self);
937 let db = Arc::clone(state);
938 tokio::spawn(async move {
939 let mut ticker = tokio::time::interval(node.config.heartbeat_interval);
940 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
941 loop {
942 ticker.tick().await;
943 if db.deposed.load(Ordering::Acquire) {
944 return;
945 }
946 let _ = db
947 .broadcast
948 .send(pb::subscribe_item::Item::Heartbeat(pb::Heartbeat {
949 basis_t: db.db().basis_t(),
950 }));
951 }
952 });
953 }
954
955 fn spawn_indexing(self: &Arc<Self>, state: &Arc<DbState>) {
959 const POLICY_POLL: Duration = Duration::from_secs(1);
963 let node = Arc::clone(self);
964 let db = Arc::clone(state);
965 tokio::spawn(async move {
966 let mut published_at = Instant::now();
967 let mut last_duration = Duration::ZERO;
968 let mut published_len: Option<u64> = None;
969 loop {
970 let policy = db.index_policy();
971 tokio::time::sleep(policy.interval.min(POLICY_POLL)).await;
972 if db.deposed.load(Ordering::Acquire) {
973 return;
974 }
975 let snapshot = db.db();
976 if snapshot.basis_t() <= db.index_basis() {
977 continue;
978 }
979 let recorded_len = u64::try_from(snapshot.recorded_len()).unwrap_or(u64::MAX);
980 let pending = published_len.map(|len| recorded_len.saturating_sub(len));
981 if !policy.due(published_at.elapsed(), last_duration, pending) {
982 continue;
983 }
984 match node.publish_db_indexes(&db).await {
985 Ok((_, duration)) => {
986 last_duration = duration;
987 published_len = Some(recorded_len);
991 }
992 Err(NodeError::Deposed(_)) => return,
993 Err(_) => {}
994 }
995 published_at = Instant::now();
996 }
997 });
998 }
999
1000 async fn publish_db_indexes(&self, db: &Arc<DbState>) -> Result<(u64, Duration), NodeError> {
1005 let _gc = self.gc_lock.lock().await;
1006 let version = db.lease().version;
1007 let root_name = db_root_name(&db.name);
1008 let started = Instant::now();
1009 let published = db
1010 .transactor
1011 .publish_indexes(self.store.as_ref(), &root_name, version)
1012 .await;
1013 let duration = started.elapsed();
1014 self.metrics.record_index(duration);
1015 match published {
1016 Ok(root) => {
1017 tracing::debug!(db = %db.name, index_basis_t = root.index_basis_t, "published indexes");
1018 db.index_basis.store(root.index_basis_t, Ordering::Release);
1019 let _ = db
1020 .broadcast
1021 .send(pb::subscribe_item::Item::IndexBasis(pb::IndexBasis {
1022 index_basis_t: root.index_basis_t,
1023 }));
1024 Ok((root.index_basis_t, duration))
1025 }
1026 Err(TransactError::Deposed { .. }) => {
1027 self.depose(db, "database root fenced by a newer lease");
1028 Err(NodeError::Deposed(db.name.clone()))
1029 }
1030 Err(error) => Err(error.into()),
1031 }
1032 }
1033
1034 pub async fn request_index(&self, name: &str) -> Result<u64, NodeError> {
1043 let state = self.db_state(name).await?;
1044 if state.db().basis_t() <= state.index_basis() {
1045 return Ok(state.index_basis());
1046 }
1047 self.publish_db_indexes(&state)
1048 .await
1049 .map(|(index_basis_t, _)| index_basis_t)
1050 }
1051
1052 pub async fn set_index_policy(
1059 &self,
1060 name: &str,
1061 update: IndexPolicyUpdate,
1062 ) -> Result<IndexPolicy, NodeError> {
1063 let state = self.db_state(name).await?;
1064 let mut policy = state
1065 .index_policy
1066 .lock()
1067 .unwrap_or_else(std::sync::PoisonError::into_inner);
1068 policy.apply(update);
1069 Ok(*policy)
1070 }
1071
1072 pub async fn db_state(&self, name: &str) -> Result<Arc<DbState>, NodeError> {
1078 if let Some(state) = self
1079 .dbs
1080 .read()
1081 .unwrap_or_else(std::sync::PoisonError::into_inner)
1082 .get(name)
1083 .cloned()
1084 {
1085 return Ok(state);
1086 }
1087 if self.config.ha
1088 && self
1089 .standby
1090 .read()
1091 .unwrap_or_else(std::sync::PoisonError::into_inner)
1092 .contains(name)
1093 {
1094 let root = self
1095 .store
1096 .get_root(&db_root_name(name))
1097 .await?
1098 .as_deref()
1099 .and_then(DbRoot::decode);
1100 return Err(NodeError::Standby {
1101 db: name.to_owned(),
1102 owner: root.as_ref().map(|r| r.owner.clone()).unwrap_or_default(),
1103 endpoint: root.map(|r| r.owner_endpoint).unwrap_or_default(),
1104 });
1105 }
1106 Err(NodeError::UnknownDb(name.to_owned()))
1107 }
1108
1109 #[must_use]
1111 pub fn standby_dbs(&self) -> Vec<String> {
1112 self.standby
1113 .read()
1114 .unwrap_or_else(std::sync::PoisonError::into_inner)
1115 .iter()
1116 .cloned()
1117 .collect()
1118 }
1119
1120 pub async fn create_db(
1126 self: &Arc<Self>,
1127 name: &str,
1128 schema_edn: &[u8],
1129 ) -> Result<bool, NodeError> {
1130 if !valid_db_name(name) {
1131 return Err(NodeError::InvalidName(name.to_owned()));
1132 }
1133 if self
1134 .dbs
1135 .read()
1136 .unwrap_or_else(std::sync::PoisonError::into_inner)
1137 .contains_key(name)
1138 {
1139 return Ok(false);
1140 }
1141 let forms = match codec::decode_edn(schema_edn)? {
1142 Edn::Vector(items) | Edn::List(items) => items,
1143 Edn::Nil => Vec::new(),
1144 other => {
1145 return Err(NodeError::BadRequest(format!(
1146 "schema must be a vector of attribute maps, got {other}"
1147 )));
1148 }
1149 };
1150 let (schema, idents) = schema_from_edn(&forms)?;
1151 let meta = codec::encode_metadata(&schema, &idents, &KeywordInterner::default());
1152 match self
1153 .store
1154 .cas_root(&meta_root_name(name), None, &meta)
1155 .await
1156 {
1157 Ok(()) => {}
1158 Err(StoreError::CasFailed { .. }) => return Ok(false),
1159 Err(error) => return Err(error.into()),
1160 }
1161 let state = self.open_db(name).await?;
1162 self.dbs
1163 .write()
1164 .unwrap_or_else(std::sync::PoisonError::into_inner)
1165 .insert(name.to_owned(), state);
1166 Ok(true)
1167 }
1168
1169 pub async fn fork_db(
1180 self: &Arc<Self>,
1181 source: &str,
1182 target: &str,
1183 as_of_t: u64,
1184 ) -> Result<Option<u64>, NodeError> {
1185 if !valid_db_name(target) {
1186 return Err(NodeError::InvalidName(target.to_owned()));
1187 }
1188 if source == target {
1189 return Err(NodeError::BadRequest(
1190 "fork target must differ from the source".into(),
1191 ));
1192 }
1193 let state = self.db_state(source).await?;
1194 let basis = state.db().basis_t();
1195 let t = if as_of_t == 0 { basis } else { as_of_t };
1196 if t > basis {
1197 return Err(NodeError::BadRequest(format!(
1198 "as-of t {t} is ahead of {source:?} basis {basis}"
1199 )));
1200 }
1201 let _guard = self.fork_lock.lock().await;
1202 if self
1203 .dbs
1204 .read()
1205 .unwrap_or_else(std::sync::PoisonError::into_inner)
1206 .contains_key(target)
1207 || self
1208 .store
1209 .get_root(&meta_root_name(target))
1210 .await?
1211 .is_some()
1212 || self.log_backend.exists(target).await
1213 {
1214 return Ok(None);
1215 }
1216 let records = state.log.tx_range_async(0, Some(t + 1)).await?;
1222 let meta = self
1223 .store
1224 .get_root(&meta_root_name(source))
1225 .await?
1226 .ok_or_else(|| NodeError::UnknownDb(source.to_owned()))?;
1227 let log = self.log_backend.open(target, 0).await?;
1232 for record in &records {
1233 log.append_async(record).await?;
1234 }
1235 drop(log);
1236 match self
1237 .store
1238 .cas_root(&meta_root_name(target), None, &meta)
1239 .await
1240 {
1241 Ok(()) => {}
1242 Err(StoreError::CasFailed { .. }) => {
1243 self.log_backend.delete_all(target).await?;
1245 return Ok(None);
1246 }
1247 Err(error) => return Err(error.into()),
1248 }
1249 let state = self.open_db(target).await?;
1250 self.dbs
1251 .write()
1252 .unwrap_or_else(std::sync::PoisonError::into_inner)
1253 .insert(target.to_owned(), state);
1254 Ok(Some(t))
1255 }
1256
1257 pub async fn delete_db(&self, name: &str) -> Result<bool, NodeError> {
1263 let Some(state) = self
1264 .dbs
1265 .write()
1266 .unwrap_or_else(std::sync::PoisonError::into_inner)
1267 .remove(name)
1268 else {
1269 return Ok(false);
1270 };
1271 state.deposed.store(true, Ordering::Release);
1272 self.standby
1273 .write()
1274 .unwrap_or_else(std::sync::PoisonError::into_inner)
1275 .remove(name);
1276 self.store.delete_root(&db_root_name(name)).await?;
1277 self.store.delete_root(&meta_root_name(name)).await?;
1278 self.log_backend.delete_all(name).await?;
1279 Ok(true)
1280 }
1281
1282 #[must_use]
1284 pub fn list_dbs(&self) -> Vec<String> {
1285 let mut names: Vec<String> = self
1286 .dbs
1287 .read()
1288 .unwrap_or_else(std::sync::PoisonError::into_inner)
1289 .keys()
1290 .cloned()
1291 .collect();
1292 names.sort();
1293 names
1294 }
1295
1296 pub async fn gc_deleted(&self) -> Result<u64, NodeError> {
1302 self.gc_deleted_with_retention(self.config.gc_retention)
1303 .await
1304 }
1305
1306 pub async fn gc_deleted_with_retention(&self, retention: Duration) -> Result<u64, NodeError> {
1311 let _gc = self.gc_lock.lock().await;
1312 let mut live = Vec::new();
1313 for root_name in self.store.list_roots("db:").await? {
1314 if let Some(root) = self
1315 .store
1316 .get_root(&root_name)
1317 .await?
1318 .as_deref()
1319 .and_then(DbRoot::decode)
1320 && let Some(roots) = root.roots
1321 {
1322 live.extend(roots);
1323 }
1324 }
1325 let report = mark_and_sweep_retained(
1326 self.store.as_ref(),
1327 live,
1328 |_, bytes| corium_store::index_blob_children(bytes),
1329 retention,
1330 SystemTime::now(),
1331 )
1332 .await?;
1333 self.metrics
1334 .record_gc(report.swept as u64, report.retained as u64);
1335 tracing::info!(
1336 marked = report.marked,
1337 swept = report.swept,
1338 retained = report.retained,
1339 "garbage collection completed"
1340 );
1341 Ok(report.swept as u64)
1342 }
1343
1344 pub async fn transact(
1351 &self,
1352 name: &str,
1353 tx_data: &[u8],
1354 ) -> Result<pb::TransactResponse, NodeError> {
1355 self.transact_at(name, tx_data, None).await
1356 }
1357
1358 pub async fn transact_at(
1368 &self,
1369 name: &str,
1370 tx_data: &[u8],
1371 expected_basis_t: Option<u64>,
1372 ) -> Result<pb::TransactResponse, NodeError> {
1373 let started = Instant::now();
1374 let result = self
1375 .transact_inner(name, tx_data, expected_basis_t)
1376 .instrument(tracing::info_span!("transact", db = name))
1377 .await;
1378 self.metrics.record_tx(started.elapsed(), result.is_ok());
1379 if let Err(error) = &result {
1380 tracing::warn!(%error, "transaction failed");
1381 }
1382 result
1383 }
1384
1385 async fn transact_inner(
1386 &self,
1387 name: &str,
1388 tx_data: &[u8],
1389 expected_basis_t: Option<u64>,
1390 ) -> Result<pb::TransactResponse, NodeError> {
1391 let state = self.db_state(name).await?;
1392 let decoded = codec::decode_edn(tx_data)?;
1393 let forms = decoded
1394 .as_seq()
1395 .ok_or_else(|| NodeError::BadRequest("tx-data must be a vector".into()))?
1396 .to_vec();
1397 let (resp_tx, mut resp_rx) = oneshot::channel();
1403 state
1404 .pending
1405 .lock()
1406 .unwrap_or_else(std::sync::PoisonError::into_inner)
1407 .push_back(CommitRequest {
1408 forms,
1409 expected_basis_t,
1410 resp: resp_tx,
1411 });
1412 let queued = self.metrics.queue_waiter();
1413 loop {
1414 let commit = state.commit.lock().await;
1415 match resp_rx.try_recv() {
1417 Ok(result) => {
1418 drop(commit);
1419 drop(queued);
1420 return result;
1421 }
1422 Err(oneshot::error::TryRecvError::Empty) => {}
1423 Err(oneshot::error::TryRecvError::Closed) => {
1424 drop(commit);
1425 drop(queued);
1426 return Err(NodeError::GroupCommit("commit response dropped".into()));
1427 }
1428 }
1429 self.flush_commit_batch(&state).await;
1431 drop(commit);
1432 match resp_rx.try_recv() {
1433 Ok(result) => {
1434 drop(queued);
1435 return result;
1436 }
1437 Err(oneshot::error::TryRecvError::Empty) => {}
1440 Err(oneshot::error::TryRecvError::Closed) => {
1441 drop(queued);
1442 return Err(NodeError::GroupCommit("commit response dropped".into()));
1443 }
1444 }
1445 }
1446 }
1447
1448 #[allow(clippy::too_many_lines)]
1457 async fn flush_commit_batch(&self, state: &Arc<DbState>) {
1458 let max_count = self.config.max_commit_batch.max(1);
1459 let max_bytes = self.config.max_commit_batch_bytes;
1460
1461 let mut batch: VecDeque<CommitRequest> = {
1462 let mut pending = state
1463 .pending
1464 .lock()
1465 .unwrap_or_else(std::sync::PoisonError::into_inner);
1466 std::mem::take(&mut *pending)
1467 };
1468 if batch.is_empty() {
1469 return;
1470 }
1471 let now_ms = now_unix_ms();
1480 let mut cursor = state.transactor.batch_cursor();
1481 let mut resps: Vec<oneshot::Sender<Result<pb::TransactResponse, NodeError>>> = Vec::new();
1482 let mut prepared: Vec<Prepared> = Vec::new();
1483 let mut batch_bytes: usize = 0;
1484 let mut measure = Vec::new();
1485 let mut naming_changed = false;
1486 while let Some(request) = batch.pop_front() {
1487 if let Some(expected) = request.expected_basis_t {
1488 let actual = cursor.db().basis_t();
1489 if actual != expected {
1490 let _ = request
1491 .resp
1492 .send(Err(NodeError::BasisMismatch { expected, actual }));
1493 continue;
1494 }
1495 }
1496 let forms = if let Some(expander) = &self.config.tx_fn_expander {
1500 let expander = Arc::clone(expander);
1501 let db = cursor.db().clone();
1502 let forms = request.forms;
1503 match tokio::task::spawn_blocking(move || expander.expand(&db, forms)).await {
1504 Ok(Ok(forms)) => forms,
1505 Ok(Err(message)) => {
1506 let _ = request.resp.send(Err(NodeError::BadRequest(message)));
1507 continue;
1508 }
1509 Err(error) => {
1510 let _ = request.resp.send(Err(NodeError::BadRequest(format!(
1511 "expander task failed: {error}"
1512 ))));
1513 continue;
1514 }
1515 }
1516 } else {
1517 request.forms
1518 };
1519 let (items, this_changed) = {
1522 let mut naming = state
1523 .naming
1524 .lock()
1525 .unwrap_or_else(std::sync::PoisonError::into_inner);
1526 let before = naming.interner.len();
1527 match tx_items_from_edn(cursor.db(), &mut naming.interner, &forms) {
1528 Ok(items) => (items, naming.interner.len() > before),
1529 Err(error) => {
1530 drop(naming);
1531 let _ = request.resp.send(Err(error.into()));
1532 continue;
1533 }
1534 }
1535 };
1536 match cursor.prepare(items, now_ms) {
1537 Ok(prep) => {
1538 measure.clear();
1539 let _ = corium_log::append_framed_record(&mut measure, &prep.record);
1540 batch_bytes += measure.len();
1541 resps.push(request.resp);
1542 prepared.push(prep);
1543 }
1544 Err(error) => {
1545 let _ = request.resp.send(Err(NodeError::Transact(error.into())));
1546 continue;
1547 }
1548 }
1549 if this_changed {
1550 naming_changed = true;
1551 break;
1552 }
1553 if prepared.len() >= max_count || batch_bytes >= max_bytes {
1557 break;
1558 }
1559 }
1560 if !batch.is_empty() {
1562 let mut pending = state
1563 .pending
1564 .lock()
1565 .unwrap_or_else(std::sync::PoisonError::into_inner);
1566 while let Some(request) = batch.pop_back() {
1567 pending.push_front(request);
1568 }
1569 }
1570 if prepared.is_empty() {
1571 return;
1572 }
1573 let interner = {
1576 let naming = state
1577 .naming
1578 .lock()
1579 .unwrap_or_else(std::sync::PoisonError::into_inner);
1580 naming.interner.clone()
1581 };
1582 let changed_idents = if naming_changed {
1583 if let Err(error) = state.check_lease(self.store.as_ref()).await {
1587 if matches!(error, NodeError::Deposed(_)) {
1588 self.depose(state, "write lease lost before metadata publish");
1589 }
1590 for resp in resps {
1591 let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1592 }
1593 return;
1594 }
1595 let (idents, schema) = {
1596 let naming = state
1597 .naming
1598 .lock()
1599 .unwrap_or_else(std::sync::PoisonError::into_inner);
1600 (naming.idents.clone(), naming.schema.clone())
1601 };
1602 let meta = codec::encode_metadata(&schema, &idents, &interner);
1605 loop {
1606 let cas = match self.store.get_root(&meta_root_name(&state.name)).await {
1607 Ok(current) => {
1608 self.store
1609 .cas_root(&meta_root_name(&state.name), current.as_deref(), &meta)
1610 .await
1611 }
1612 Err(error) => Err(error),
1613 };
1614 match cas {
1615 Ok(()) => break,
1616 Err(StoreError::CasFailed { .. }) => {}
1617 Err(error) => {
1618 let error = NodeError::Store(error);
1619 for resp in resps {
1620 let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1621 }
1622 return;
1623 }
1624 }
1625 }
1626 Some(idents)
1627 } else {
1628 None
1629 };
1630 let records: Vec<TxRecord> = prepared.iter().map(|prep| prep.record.clone()).collect();
1632 if let Err(error) = state.log.append_batch_async(&records).await {
1633 let error = NodeError::Log(error);
1634 for resp in resps {
1635 let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1636 }
1637 return;
1638 }
1639 let reports = state.transactor.install_batch(cursor, prepared);
1645 if let Some(idents) = changed_idents {
1646 state.transactor.update_naming(idents, interner.clone());
1647 }
1648 if let Err(error) = state.check_lease(self.store.as_ref()).await {
1657 if matches!(error, NodeError::Deposed(_)) {
1658 self.depose(state, "write lease lost after durable append");
1659 }
1660 for resp in resps {
1661 let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1662 }
1663 return;
1664 }
1665 let mut last_t = 0;
1666 for (resp, report) in resps.into_iter().zip(reports) {
1667 let t = report.db_after.basis_t();
1668 last_t = last_t.max(t);
1669 let datoms = match codec::encode_datoms(&report.tx.datoms, &interner) {
1670 Ok(datoms) => datoms,
1671 Err(error) => {
1672 let _ = resp.send(Err(NodeError::Codec(error)));
1673 continue;
1674 }
1675 };
1676 let tempids = codec::encode_edn(&Edn::Map(
1677 report
1678 .tx
1679 .tempids
1680 .iter()
1681 .map(|(tempid, eid)| {
1682 (
1683 Edn::Str(tempid.clone()),
1684 Edn::Long(i64::try_from(eid.raw()).unwrap_or(i64::MAX)),
1685 )
1686 })
1687 .collect(),
1688 ));
1689 let _ = state
1690 .broadcast
1691 .send(pb::subscribe_item::Item::Report(pb::TxReport {
1692 t,
1693 tx_instant: report.tx_instant,
1694 datoms: datoms.clone(),
1695 }));
1696 let _ = resp.send(Ok(pb::TransactResponse {
1697 basis_before: report.db_before.basis_t(),
1698 basis_t: t,
1699 tx_instant: report.tx_instant,
1700 tempids,
1701 tx_data: datoms,
1702 }));
1703 }
1704 if last_t > 0 {
1705 let _ = state.basis.send(last_t);
1706 }
1707 }
1708
1709 pub async fn status(&self, name: &str) -> Result<pb::StatusResponse, NodeError> {
1714 let state = self.db_state(name).await?;
1715 let db = state.db();
1716 let counts = db.stats();
1717 let held = state.lease();
1718 let metrics = self.metrics.snapshot();
1719 Ok(pb::StatusResponse {
1720 basis_t: db.basis_t(),
1721 index_basis_t: state.index_basis(),
1722 lease_owner: held.owner,
1723 lease_version: held.version,
1724 lease_expires_unix_ms: held.expires_unix_ms,
1725 datom_count: counts.datoms as u64,
1726 entity_count: counts.entities as u64,
1727 attribute_count: counts.attributes as u64,
1728 transaction_count: metrics.tx_total,
1729 transaction_failure_count: metrics.tx_failed,
1730 transaction_queue_depth: metrics.queue_depth,
1731 index_lag: db.basis_t().saturating_sub(state.index_basis()),
1732 indexing_runs: metrics.index_runs,
1733 gc_runs: metrics.gc_runs,
1734 gc_swept_blobs: metrics.gc_swept,
1735 lease_owner_endpoint: held.endpoint,
1736 })
1737 }
1738
1739 pub async fn backup_info(&self, name: &str) -> Result<pb::GetStorageInfoResponse, NodeError> {
1747 let state = self.db_state(name).await?;
1748 let basis_t = {
1752 let _commit = state.commit.lock().await;
1753 state.check_lease(self.store.as_ref()).await?;
1754 state.db().basis_t()
1755 };
1756 let storage = self
1760 .config
1761 .store
1762 .connection_info(&self.config.data_dir, &self.config.storage_info)
1763 .await
1764 .map_err(NodeError::BadRequest)?;
1765 Ok(pb::GetStorageInfoResponse {
1766 basis_t,
1767 storage: Some(storage),
1768 })
1769 }
1770
1771 pub async fn release_leases(&self) {
1776 let states: Vec<Arc<DbState>> = self
1777 .dbs
1778 .write()
1779 .unwrap_or_else(std::sync::PoisonError::into_inner)
1780 .drain()
1781 .map(|(_, state)| state)
1782 .collect();
1783 for state in states {
1784 state.deposed.store(true, Ordering::Release);
1785 if let Err(error) =
1786 lease::release(self.store.as_ref(), &state.name, &state.lease()).await
1787 {
1788 tracing::warn!(db = %state.name, %error, "lease release failed at shutdown");
1789 }
1790 }
1791 }
1792
1793 pub async fn sync(&self, name: &str, t: u64) -> Result<u64, NodeError> {
1798 let state = self.db_state(name).await?;
1799 let mut basis = state.basis_watch();
1800 let target = if t == 0 { *basis.borrow() } else { t };
1801 loop {
1802 let current = *basis.borrow();
1803 if current >= target {
1804 return Ok(current);
1805 }
1806 if basis.changed().await.is_err() {
1807 return Ok(*basis.borrow());
1808 }
1809 }
1810 }
1811}
1812
1813#[cfg(test)]
1814mod tests {
1815 use super::IndexPolicy;
1816 use std::time::Duration;
1817
1818 fn pacing(interval_ms: u64, backoff: u32, threshold: u64, deadline_ms: u64) -> IndexPolicy {
1819 IndexPolicy {
1820 interval: Duration::from_millis(interval_ms),
1821 backoff,
1822 tail_threshold: threshold,
1823 tail_deadline: Duration::from_millis(deadline_ms),
1824 }
1825 }
1826
1827 #[test]
1828 fn base_interval_gates_publication() {
1829 let pacing = pacing(100, 4, 0, 60_000);
1830 assert!(!pacing.due(Duration::from_millis(99), Duration::ZERO, None));
1831 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1832 }
1833
1834 #[test]
1835 fn backoff_stretches_the_floor_past_the_interval() {
1836 let pacing = pacing(100, 4, 0, 60_000);
1837 let last = Duration::from_millis(300);
1838 assert!(!pacing.due(Duration::from_millis(1_199), last, Some(10)));
1839 assert!(pacing.due(Duration::from_millis(1_200), last, Some(10)));
1840 }
1841
1842 #[test]
1843 fn zero_backoff_keeps_the_base_interval() {
1844 let pacing = pacing(100, 0, 0, 60_000);
1845 assert!(pacing.due(
1846 Duration::from_millis(100),
1847 Duration::from_secs(30),
1848 Some(10)
1849 ));
1850 }
1851
1852 #[test]
1853 fn fast_publications_leave_the_interval_untouched() {
1854 let pacing = pacing(5_000, 4, 0, 60_000);
1855 assert!(pacing.due(Duration::from_secs(5), Duration::from_millis(3), Some(1)));
1856 }
1857
1858 #[test]
1859 fn small_tail_defers_until_the_deadline() {
1860 let pacing = pacing(100, 4, 1_000, 60_000);
1861 assert!(!pacing.due(Duration::from_secs(30), Duration::ZERO, Some(999)));
1862 assert!(pacing.due(Duration::from_secs(60), Duration::ZERO, Some(999)));
1863 }
1864
1865 #[test]
1866 fn tail_at_threshold_publishes_at_base_pacing() {
1867 let pacing = pacing(100, 4, 1_000, 60_000);
1868 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, Some(1_000)));
1869 }
1870
1871 #[test]
1872 fn unknown_tail_publishes_at_base_pacing() {
1873 let pacing = pacing(100, 4, 1_000, 60_000);
1874 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1875 }
1876
1877 #[test]
1878 fn deadline_never_overrides_the_backoff_floor() {
1879 let pacing = pacing(100, 4, 1_000, 200);
1880 let last = Duration::from_millis(300);
1881 assert!(!pacing.due(Duration::from_millis(400), last, Some(1)));
1882 assert!(pacing.due(Duration::from_millis(1_200), last, Some(1)));
1883 }
1884}