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, 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 data_dir: PathBuf,
54 pub owner: String,
56 pub lease_ttl_ms: i64,
58 pub lease_wait_ms: i64,
60 pub ha: bool,
64 pub advertise: Option<String>,
67 pub index_interval: Duration,
69 pub index_backoff: u32,
75 pub index_tail_threshold: u64,
79 pub index_tail_deadline: Duration,
81 pub heartbeat_interval: Duration,
83 pub gc_interval: Option<Duration>,
85 pub gc_retention: Duration,
87 pub max_commit_batch: usize,
93 pub max_commit_batch_bytes: usize,
98 pub tx_fn_expander: Option<Arc<dyn TxFnExpander>>,
100}
101
102impl std::fmt::Debug for NodeConfig {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.debug_struct("NodeConfig")
105 .field("store", &self.store)
106 .field("data_dir", &self.data_dir)
107 .field("owner", &self.owner)
108 .field("lease_ttl_ms", &self.lease_ttl_ms)
109 .field("lease_wait_ms", &self.lease_wait_ms)
110 .field("ha", &self.ha)
111 .field("advertise", &self.advertise)
112 .field("index_interval", &self.index_interval)
113 .field("index_backoff", &self.index_backoff)
114 .field("index_tail_threshold", &self.index_tail_threshold)
115 .field("index_tail_deadline", &self.index_tail_deadline)
116 .field("heartbeat_interval", &self.heartbeat_interval)
117 .field("gc_interval", &self.gc_interval)
118 .field("gc_retention", &self.gc_retention)
119 .field("max_commit_batch", &self.max_commit_batch)
120 .field("max_commit_batch_bytes", &self.max_commit_batch_bytes)
121 .field("tx_fn_expander", &self.tx_fn_expander.is_some())
122 .finish()
123 }
124}
125
126impl NodeConfig {
127 #[must_use]
129 pub fn new(data_dir: PathBuf) -> Self {
130 Self {
131 store: StoreSpec::Fs,
132 data_dir,
133 owner: format!(
134 "transactor-{}",
135 std::env::var("HOSTNAME").unwrap_or_else(|_| "local".into())
136 ),
137 lease_ttl_ms: 5_000,
138 lease_wait_ms: 15_000,
139 ha: false,
140 advertise: None,
141 index_interval: Duration::from_secs(5),
142 index_backoff: 4,
143 index_tail_threshold: 0,
144 index_tail_deadline: Duration::from_secs(60),
145 heartbeat_interval: Duration::from_secs(10),
146 gc_interval: Some(Duration::from_secs(60 * 60)),
147 gc_retention: Duration::from_secs(72 * 60 * 60),
148 max_commit_batch: 256,
149 max_commit_batch_bytes: 4 * 1024 * 1024,
150 #[cfg(feature = "cljrs")]
151 tx_fn_expander: Some(Arc::new(crate::txfn::DbFnExpander::default())),
152 #[cfg(not(feature = "cljrs"))]
153 tx_fn_expander: None,
154 }
155 }
156}
157
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
171pub struct IndexPolicy {
172 pub interval: Duration,
174 pub backoff: u32,
177 pub tail_threshold: u64,
180 pub tail_deadline: Duration,
183}
184
185#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
187pub struct IndexPolicyUpdate {
188 pub interval: Option<Duration>,
190 pub backoff: Option<u32>,
192 pub tail_threshold: Option<u64>,
194 pub tail_deadline: Option<Duration>,
196}
197
198impl IndexPolicy {
199 fn from_config(config: &NodeConfig) -> Self {
200 Self {
201 interval: config.index_interval,
202 backoff: config.index_backoff,
203 tail_threshold: config.index_tail_threshold,
204 tail_deadline: config.index_tail_deadline,
205 }
206 }
207
208 fn apply(&mut self, update: IndexPolicyUpdate) {
209 if let Some(interval) = update.interval {
210 self.interval = interval;
211 }
212 if let Some(backoff) = update.backoff {
213 self.backoff = backoff;
214 }
215 if let Some(tail_threshold) = update.tail_threshold {
216 self.tail_threshold = tail_threshold;
217 }
218 if let Some(tail_deadline) = update.tail_deadline {
219 self.tail_deadline = tail_deadline;
220 }
221 }
222
223 fn due(&self, since_publish: Duration, last_duration: Duration, pending: Option<u64>) -> bool {
230 let floor = self
231 .interval
232 .max(last_duration.saturating_mul(self.backoff));
233 if since_publish < floor {
234 return false;
235 }
236 match pending {
237 Some(pending) if pending < self.tail_threshold => since_publish >= self.tail_deadline,
238 _ => true,
239 }
240 }
241}
242
243#[derive(Debug, Error)]
245pub enum NodeError {
246 #[error("unknown database {0:?}")]
248 UnknownDb(String),
249 #[error("invalid database name {0:?}")]
251 InvalidName(String),
252 #[error("storage format {found} is newer than supported format {supported}")]
254 UnsupportedFormat {
255 found: u32,
257 supported: u32,
259 },
260 #[error("deposed: write lease for {0:?} is held elsewhere")]
262 Deposed(String),
263 #[error("standby for {db:?}: lease held by {owner} at {endpoint:?}")]
266 Standby {
267 db: String,
269 owner: String,
271 endpoint: String,
273 },
274 #[error(transparent)]
276 Codec(#[from] CodecError),
277 #[error(transparent)]
279 TxForm(#[from] TxFormError),
280 #[error(transparent)]
282 SchemaForm(#[from] SchemaFormError),
283 #[error(transparent)]
285 Transact(#[from] TransactError),
286 #[error(transparent)]
288 Store(#[from] StoreError),
289 #[error(transparent)]
291 Log(#[from] LogError),
292 #[error(transparent)]
294 Lease(#[from] LeaseError),
295 #[error("bad request: {0}")]
297 BadRequest(String),
298 #[error("basis changed: expected {expected}, current basis is {actual}")]
300 BasisMismatch {
301 expected: u64,
303 actual: u64,
305 },
306 #[error("group commit aborted: {0}")]
311 GroupCommit(String),
312}
313
314struct Naming {
315 schema: Schema,
316 idents: Idents,
317 interner: KeywordInterner,
318}
319
320struct CommitRequest {
323 forms: Vec<Edn>,
324 expected_basis_t: Option<u64>,
325 resp: oneshot::Sender<Result<pb::TransactResponse, NodeError>>,
326}
327
328pub struct DbState {
330 name: String,
331 transactor: EmbeddedTransactor,
332 log: Arc<dyn TransactionLog>,
333 naming: Mutex<Naming>,
334 commit: tokio::sync::Mutex<()>,
338 pending: Mutex<VecDeque<CommitRequest>>,
340 broadcast: broadcast::Sender<pb::subscribe_item::Item>,
341 basis: watch::Sender<u64>,
342 index_basis: AtomicU64,
343 index_policy: Mutex<IndexPolicy>,
344 held_lease: Mutex<Lease>,
345 deposed: AtomicBool,
346}
347
348impl DbState {
349 #[must_use]
351 pub fn name(&self) -> &str {
352 &self.name
353 }
354
355 #[must_use]
357 pub fn db(&self) -> Db {
358 self.transactor.db()
359 }
360
361 #[must_use]
363 pub fn basis_watch(&self) -> watch::Receiver<u64> {
364 self.basis.subscribe()
365 }
366
367 #[must_use]
370 pub fn stream_items(&self) -> broadcast::Receiver<pb::subscribe_item::Item> {
371 self.broadcast.subscribe()
372 }
373
374 #[must_use]
376 pub fn index_basis(&self) -> u64 {
377 self.index_basis.load(Ordering::Acquire)
378 }
379
380 #[must_use]
382 pub fn index_policy(&self) -> IndexPolicy {
383 *self
384 .index_policy
385 .lock()
386 .unwrap_or_else(std::sync::PoisonError::into_inner)
387 }
388
389 #[must_use]
391 pub fn lease(&self) -> Lease {
392 self.held_lease
393 .lock()
394 .unwrap_or_else(std::sync::PoisonError::into_inner)
395 .clone()
396 }
397
398 #[must_use]
401 pub fn handshake_snapshot(&self) -> (Vec<u8>, KeywordInterner) {
402 let naming = self
403 .naming
404 .lock()
405 .unwrap_or_else(std::sync::PoisonError::into_inner);
406 (
407 codec::encode_schema(&naming.schema, &naming.idents),
408 naming.interner.clone(),
409 )
410 }
411
412 pub async fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, NodeError> {
417 Ok(self.log.tx_range_async(start, end).await?)
418 }
419
420 async fn check_lease(&self, store: &dyn RootStore) -> Result<Lease, NodeError> {
423 if self.deposed.load(Ordering::Acquire) {
424 return Err(NodeError::Deposed(self.name.clone()));
425 }
426 let held = self.lease();
427 match lease::verify(store, &self.name, &held).await {
428 Ok(()) => Ok(held),
429 Err(LeaseError::Lost) => {
430 self.deposed.store(true, Ordering::Release);
431 Err(NodeError::Deposed(self.name.clone()))
432 }
433 Err(error) => Err(error.into()),
434 }
435 }
436}
437
438pub struct TransactorNode {
440 config: NodeConfig,
441 store: Arc<NodeStore>,
442 log_backend: LogBackend,
443 dbs: std::sync::RwLock<HashMap<String, Arc<DbState>>>,
444 standby: std::sync::RwLock<BTreeSet<String>>,
447 gc_lock: tokio::sync::Mutex<()>,
448 fork_lock: tokio::sync::Mutex<()>,
451 metrics: Metrics,
452 shutdown: watch::Sender<Option<String>>,
453}
454
455fn now_unix_ms() -> i64 {
456 i64::try_from(
457 SystemTime::now()
458 .duration_since(UNIX_EPOCH)
459 .unwrap_or_default()
460 .as_millis(),
461 )
462 .unwrap_or(i64::MAX)
463}
464
465fn batch_abort_error(name: &str, error: &NodeError) -> NodeError {
471 match error {
472 NodeError::Deposed(_) => NodeError::Deposed(name.to_owned()),
473 other => NodeError::GroupCommit(other.to_string()),
474 }
475}
476
477fn valid_db_name(name: &str) -> bool {
478 !name.is_empty()
479 && name.len() <= 128
480 && name
481 .bytes()
482 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
483}
484
485impl TransactorNode {
486 pub async fn open(config: NodeConfig) -> Result<Arc<Self>, NodeError> {
494 let store = Arc::new(NodeStore::open(&config.store, &config.data_dir).await?);
495 let log_backend = LogBackend::for_spec(&config.store, &config.data_dir, Arc::clone(&store));
496 let node = Arc::new(Self {
497 config,
498 store,
499 log_backend,
500 dbs: std::sync::RwLock::new(HashMap::new()),
501 standby: std::sync::RwLock::new(BTreeSet::new()),
502 gc_lock: tokio::sync::Mutex::new(()),
503 fork_lock: tokio::sync::Mutex::new(()),
504 metrics: Metrics::default(),
505 shutdown: watch::channel(None).0,
506 });
507 let names: Vec<String> = node
508 .store
509 .list_roots("meta:")
510 .await?
511 .into_iter()
512 .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
513 .collect();
514 for name in names {
515 match node.open_db(&name).await {
516 Ok(state) => {
517 node.dbs
518 .write()
519 .unwrap_or_else(std::sync::PoisonError::into_inner)
520 .insert(name, state);
521 }
522 Err(NodeError::Lease(LeaseError::Held { owner, .. })) if node.config.ha => {
523 tracing::info!(db = %name, %owner, "standing by; lease held elsewhere");
524 node.standby
525 .write()
526 .unwrap_or_else(std::sync::PoisonError::into_inner)
527 .insert(name);
528 }
529 Err(error) => return Err(error),
530 }
531 }
532 node.spawn_standby_poller();
533 node.spawn_scheduled_gc();
534 Ok(node)
535 }
536
537 #[must_use]
539 pub fn store(&self) -> &Arc<NodeStore> {
540 &self.store
541 }
542
543 #[must_use]
545 pub fn config(&self) -> &NodeConfig {
546 &self.config
547 }
548
549 #[must_use]
551 pub const fn metrics(&self) -> &Metrics {
552 &self.metrics
553 }
554
555 fn spawn_scheduled_gc(self: &Arc<Self>) {
556 let Some(interval) = self.config.gc_interval else {
557 return;
558 };
559 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
560 return;
563 };
564 let node = Arc::clone(self);
565 runtime.spawn(async move {
566 let mut ticker = tokio::time::interval(interval);
567 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
568 ticker.tick().await;
570 loop {
571 ticker.tick().await;
572 if let Err(error) = node.gc_deleted().await {
573 tracing::warn!(%error, "scheduled garbage collection failed");
574 }
575 }
576 });
577 }
578
579 #[must_use]
581 pub fn shutdown_watch(&self) -> watch::Receiver<Option<String>> {
582 self.shutdown.subscribe()
583 }
584
585 fn depose(&self, state: &DbState, reason: &str) {
589 state.deposed.store(true, Ordering::Release);
590 if self.config.ha {
591 tracing::warn!(db = %state.name, reason, "deposed; returning to standby");
592 self.dbs
593 .write()
594 .unwrap_or_else(std::sync::PoisonError::into_inner)
595 .remove(&state.name);
596 self.standby
597 .write()
598 .unwrap_or_else(std::sync::PoisonError::into_inner)
599 .insert(state.name.clone());
600 } else {
601 let _ = self
602 .shutdown
603 .send(Some(format!("database {:?}: {reason}", state.name)));
604 }
605 }
606
607 fn advertised(&self) -> &str {
608 self.config.advertise.as_deref().unwrap_or("")
609 }
610
611 async fn acquire_lease(&self, name: &str) -> Result<Lease, NodeError> {
615 let deadline = now_unix_ms() + self.config.lease_wait_ms;
616 loop {
617 match lease::acquire(
618 self.store.as_ref(),
619 name,
620 &self.config.owner,
621 self.advertised(),
622 self.config.lease_ttl_ms,
623 now_unix_ms(),
624 )
625 .await
626 {
627 Ok(held) => return Ok(held),
628 Err(LeaseError::Held { .. }) if !self.config.ha && now_unix_ms() < deadline => {
629 tokio::time::sleep(Duration::from_millis(200)).await;
630 }
631 Err(error) => return Err(error.into()),
632 }
633 }
634 }
635
636 async fn open_db(self: &Arc<Self>, name: &str) -> Result<Arc<DbState>, NodeError> {
637 let meta = self
638 .store
639 .get_root(&meta_root_name(name))
640 .await?
641 .ok_or_else(|| NodeError::UnknownDb(name.to_owned()))?;
642 let (schema, idents, interner) = codec::decode_metadata(&meta)?;
643 let root_name = db_root_name(name);
644 let current = self
645 .store
646 .get_root(&root_name)
647 .await?
648 .as_deref()
649 .and_then(DbRoot::decode);
650 if let Some(root) = ¤t
651 && root.format_version > corium_store::FORMAT_VERSION
652 {
653 return Err(NodeError::UnsupportedFormat {
654 found: root.format_version,
655 supported: corium_store::FORMAT_VERSION,
656 });
657 }
658 let held = self.acquire_lease(name).await?;
664 let log = self.log_backend.open(name, held.version).await?;
667 let post_fence = self
668 .store
669 .get_root(&root_name)
670 .await?
671 .as_deref()
672 .and_then(DbRoot::decode);
673 let transactor = self
674 .recover_transactor(name, &schema, &idents, &interner, post_fence.as_ref(), &log)
675 .await?;
676 let basis_t = transactor.db().basis_t();
677 let index_basis = post_fence.map_or(0, |root| root.index_basis_t);
678 let state = Arc::new(DbState {
679 name: name.to_owned(),
680 transactor,
681 log,
682 naming: Mutex::new(Naming {
683 schema,
684 idents,
685 interner,
686 }),
687 commit: tokio::sync::Mutex::new(()),
688 pending: Mutex::new(VecDeque::new()),
689 broadcast: broadcast::channel(1024).0,
690 basis: watch::channel(basis_t).0,
691 index_basis: AtomicU64::new(index_basis),
692 index_policy: Mutex::new(IndexPolicy::from_config(&self.config)),
693 held_lease: Mutex::new(held),
694 deposed: AtomicBool::new(false),
695 });
696 self.spawn_maintenance(&state);
697 Ok(state)
698 }
699
700 async fn recover_transactor(
709 &self,
710 name: &str,
711 schema: &Schema,
712 idents: &Idents,
713 interner: &KeywordInterner,
714 root: Option<&DbRoot>,
715 log: &Arc<dyn TransactionLog>,
716 ) -> Result<EmbeddedTransactor, NodeError> {
717 if let Some(root) = root
720 && let Some(roots) = &root.roots
721 && root.next_entity_id != 0
722 {
723 match self
724 .load_current_snapshot(
725 root,
726 &roots[IndexOrder::Eavt as usize],
727 schema,
728 idents,
729 interner,
730 )
731 .await
732 {
733 Ok(snapshot) => {
734 return Ok(EmbeddedTransactor::recover_from_snapshot_async(
735 snapshot,
736 root.next_entity_id,
737 root.last_tx_instant,
738 Arc::clone(log),
739 )
740 .await?);
741 }
742 Err(error) => {
743 tracing::warn!(
744 db = %name,
745 %error,
746 "index-root recovery failed; falling back to full-log replay"
747 );
748 }
749 }
750 }
751 let base = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
752 Ok(EmbeddedTransactor::recover_from_async(base, Arc::clone(log)).await?)
753 }
754
755 async fn load_current_snapshot(
760 &self,
761 root: &DbRoot,
762 eavt: &BlobId,
763 schema: &Schema,
764 idents: &Idents,
765 interner: &KeywordInterner,
766 ) -> Result<Db, StoreError> {
767 let datoms = self
768 .load_index_keys(eavt)
769 .await?
770 .into_iter()
771 .map(|key| Datom::from_key(IndexOrder::Eavt, &key))
772 .collect::<Result<Vec<_>, _>>()
773 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
774 Ok(Db::from_current_snapshot(
775 root.index_basis_t,
776 schema.clone(),
777 idents.clone(),
778 interner.clone(),
779 datoms,
780 ))
781 }
782
783 async fn load_index_keys(&self, id: &BlobId) -> Result<Vec<Vec<u8>>, StoreError> {
786 let blob = self
787 .store
788 .get(id)
789 .await?
790 .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
791 if !is_index_manifest(&blob) {
792 return decode_segment_keys(&blob);
793 }
794 let mut keys = Vec::new();
795 for child in decode_index_manifest(&blob)? {
796 let chunk = self
797 .store
798 .get(&child)
799 .await?
800 .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
801 keys.extend(decode_segment_keys(&chunk)?);
802 }
803 Ok(keys)
804 }
805
806 fn spawn_standby_poller(self: &Arc<Self>) {
812 if !self.config.ha {
813 return;
814 }
815 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
816 return;
817 };
818 let ttl = self.config.lease_ttl_ms;
819 let poll_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
820 let node = Arc::clone(self);
821 runtime.spawn(async move {
822 let mut ticker = tokio::time::interval(poll_every);
823 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
824 loop {
825 ticker.tick().await;
826 if let Err(error) = node.standby_scan().await {
827 tracing::warn!(%error, "standby scan failed");
828 }
829 }
830 });
831 }
832
833 async fn standby_scan(self: &Arc<Self>) -> Result<(), NodeError> {
836 let names: Vec<String> = self
837 .store
838 .list_roots("meta:")
839 .await?
840 .into_iter()
841 .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
842 .collect();
843 {
844 let mut standby = self
845 .standby
846 .write()
847 .unwrap_or_else(std::sync::PoisonError::into_inner);
848 standby.retain(|name| names.contains(name));
849 }
850 for name in names {
851 if self
852 .dbs
853 .read()
854 .unwrap_or_else(std::sync::PoisonError::into_inner)
855 .contains_key(&name)
856 {
857 continue;
858 }
859 match self.open_db(&name).await {
860 Ok(state) => {
861 tracing::info!(db = %name, owner = %self.config.owner, "standby took over write lease");
862 self.standby
863 .write()
864 .unwrap_or_else(std::sync::PoisonError::into_inner)
865 .remove(&name);
866 self.dbs
867 .write()
868 .unwrap_or_else(std::sync::PoisonError::into_inner)
869 .insert(name, state);
870 }
871 Err(NodeError::Lease(LeaseError::Held { .. })) => {
872 self.standby
873 .write()
874 .unwrap_or_else(std::sync::PoisonError::into_inner)
875 .insert(name);
876 }
877 Err(error) => {
878 tracing::warn!(db = %name, %error, "standby takeover attempt failed");
879 }
880 }
881 }
882 Ok(())
883 }
884
885 fn spawn_maintenance(self: &Arc<Self>, state: &Arc<DbState>) {
886 let ttl = self.config.lease_ttl_ms;
887 let renew_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
888 let node = Arc::clone(self);
890 let db = Arc::clone(state);
891 tokio::spawn(async move {
892 let mut ticker = tokio::time::interval(renew_every);
893 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
894 loop {
895 ticker.tick().await;
896 if db.deposed.load(Ordering::Acquire) {
897 return;
898 }
899 let _commit = db.commit.lock().await;
903 let held = db.lease();
904 let name = db.name.clone();
905 let renewed =
906 lease::renew(node.store.as_ref(), &name, &held, ttl, now_unix_ms()).await;
907 match renewed {
908 Ok(renewed) => {
909 *db.held_lease
910 .lock()
911 .unwrap_or_else(std::sync::PoisonError::into_inner) = renewed;
912 }
913 Err(LeaseError::Lost) => {
914 node.depose(&db, "write lease lost");
915 return;
916 }
917 Err(_) => {}
918 }
919 }
920 });
921 self.spawn_indexing(state);
922 let node = Arc::clone(self);
924 let db = Arc::clone(state);
925 tokio::spawn(async move {
926 let mut ticker = tokio::time::interval(node.config.heartbeat_interval);
927 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
928 loop {
929 ticker.tick().await;
930 if db.deposed.load(Ordering::Acquire) {
931 return;
932 }
933 let _ = db
934 .broadcast
935 .send(pb::subscribe_item::Item::Heartbeat(pb::Heartbeat {
936 basis_t: db.db().basis_t(),
937 }));
938 }
939 });
940 }
941
942 fn spawn_indexing(self: &Arc<Self>, state: &Arc<DbState>) {
946 const POLICY_POLL: Duration = Duration::from_secs(1);
950 let node = Arc::clone(self);
951 let db = Arc::clone(state);
952 tokio::spawn(async move {
953 let mut published_at = Instant::now();
954 let mut last_duration = Duration::ZERO;
955 let mut published_len: Option<u64> = None;
956 loop {
957 let policy = db.index_policy();
958 tokio::time::sleep(policy.interval.min(POLICY_POLL)).await;
959 if db.deposed.load(Ordering::Acquire) {
960 return;
961 }
962 let snapshot = db.db();
963 if snapshot.basis_t() <= db.index_basis() {
964 continue;
965 }
966 let recorded_len = u64::try_from(snapshot.recorded_len()).unwrap_or(u64::MAX);
967 let pending = published_len.map(|len| recorded_len.saturating_sub(len));
968 if !policy.due(published_at.elapsed(), last_duration, pending) {
969 continue;
970 }
971 match node.publish_db_indexes(&db).await {
972 Ok((_, duration)) => {
973 last_duration = duration;
974 published_len = Some(recorded_len);
978 }
979 Err(NodeError::Deposed(_)) => return,
980 Err(_) => {}
981 }
982 published_at = Instant::now();
983 }
984 });
985 }
986
987 async fn publish_db_indexes(&self, db: &Arc<DbState>) -> Result<(u64, Duration), NodeError> {
992 let _gc = self.gc_lock.lock().await;
993 let version = db.lease().version;
994 let root_name = db_root_name(&db.name);
995 let started = Instant::now();
996 let published = db
997 .transactor
998 .publish_indexes(self.store.as_ref(), &root_name, version)
999 .await;
1000 let duration = started.elapsed();
1001 self.metrics.record_index(duration);
1002 match published {
1003 Ok(root) => {
1004 tracing::debug!(db = %db.name, index_basis_t = root.index_basis_t, "published indexes");
1005 db.index_basis.store(root.index_basis_t, Ordering::Release);
1006 let _ = db
1007 .broadcast
1008 .send(pb::subscribe_item::Item::IndexBasis(pb::IndexBasis {
1009 index_basis_t: root.index_basis_t,
1010 }));
1011 Ok((root.index_basis_t, duration))
1012 }
1013 Err(TransactError::Deposed { .. }) => {
1014 self.depose(db, "database root fenced by a newer lease");
1015 Err(NodeError::Deposed(db.name.clone()))
1016 }
1017 Err(error) => Err(error.into()),
1018 }
1019 }
1020
1021 pub async fn request_index(&self, name: &str) -> Result<u64, NodeError> {
1030 let state = self.db_state(name).await?;
1031 if state.db().basis_t() <= state.index_basis() {
1032 return Ok(state.index_basis());
1033 }
1034 self.publish_db_indexes(&state)
1035 .await
1036 .map(|(index_basis_t, _)| index_basis_t)
1037 }
1038
1039 pub async fn set_index_policy(
1046 &self,
1047 name: &str,
1048 update: IndexPolicyUpdate,
1049 ) -> Result<IndexPolicy, NodeError> {
1050 let state = self.db_state(name).await?;
1051 let mut policy = state
1052 .index_policy
1053 .lock()
1054 .unwrap_or_else(std::sync::PoisonError::into_inner);
1055 policy.apply(update);
1056 Ok(*policy)
1057 }
1058
1059 pub async fn db_state(&self, name: &str) -> Result<Arc<DbState>, NodeError> {
1065 if let Some(state) = self
1066 .dbs
1067 .read()
1068 .unwrap_or_else(std::sync::PoisonError::into_inner)
1069 .get(name)
1070 .cloned()
1071 {
1072 return Ok(state);
1073 }
1074 if self.config.ha
1075 && self
1076 .standby
1077 .read()
1078 .unwrap_or_else(std::sync::PoisonError::into_inner)
1079 .contains(name)
1080 {
1081 let root = self
1082 .store
1083 .get_root(&db_root_name(name))
1084 .await?
1085 .as_deref()
1086 .and_then(DbRoot::decode);
1087 return Err(NodeError::Standby {
1088 db: name.to_owned(),
1089 owner: root.as_ref().map(|r| r.owner.clone()).unwrap_or_default(),
1090 endpoint: root.map(|r| r.owner_endpoint).unwrap_or_default(),
1091 });
1092 }
1093 Err(NodeError::UnknownDb(name.to_owned()))
1094 }
1095
1096 #[must_use]
1098 pub fn standby_dbs(&self) -> Vec<String> {
1099 self.standby
1100 .read()
1101 .unwrap_or_else(std::sync::PoisonError::into_inner)
1102 .iter()
1103 .cloned()
1104 .collect()
1105 }
1106
1107 pub async fn create_db(
1113 self: &Arc<Self>,
1114 name: &str,
1115 schema_edn: &[u8],
1116 ) -> Result<bool, NodeError> {
1117 if !valid_db_name(name) {
1118 return Err(NodeError::InvalidName(name.to_owned()));
1119 }
1120 if self
1121 .dbs
1122 .read()
1123 .unwrap_or_else(std::sync::PoisonError::into_inner)
1124 .contains_key(name)
1125 {
1126 return Ok(false);
1127 }
1128 let forms = match codec::decode_edn(schema_edn)? {
1129 Edn::Vector(items) | Edn::List(items) => items,
1130 Edn::Nil => Vec::new(),
1131 other => {
1132 return Err(NodeError::BadRequest(format!(
1133 "schema must be a vector of attribute maps, got {other}"
1134 )));
1135 }
1136 };
1137 let (schema, idents) = schema_from_edn(&forms)?;
1138 let meta = codec::encode_metadata(&schema, &idents, &KeywordInterner::default());
1139 match self
1140 .store
1141 .cas_root(&meta_root_name(name), None, &meta)
1142 .await
1143 {
1144 Ok(()) => {}
1145 Err(StoreError::CasFailed { .. }) => return Ok(false),
1146 Err(error) => return Err(error.into()),
1147 }
1148 let state = self.open_db(name).await?;
1149 self.dbs
1150 .write()
1151 .unwrap_or_else(std::sync::PoisonError::into_inner)
1152 .insert(name.to_owned(), state);
1153 Ok(true)
1154 }
1155
1156 pub async fn fork_db(
1167 self: &Arc<Self>,
1168 source: &str,
1169 target: &str,
1170 as_of_t: u64,
1171 ) -> Result<Option<u64>, NodeError> {
1172 if !valid_db_name(target) {
1173 return Err(NodeError::InvalidName(target.to_owned()));
1174 }
1175 if source == target {
1176 return Err(NodeError::BadRequest(
1177 "fork target must differ from the source".into(),
1178 ));
1179 }
1180 let state = self.db_state(source).await?;
1181 let basis = state.db().basis_t();
1182 let t = if as_of_t == 0 { basis } else { as_of_t };
1183 if t > basis {
1184 return Err(NodeError::BadRequest(format!(
1185 "as-of t {t} is ahead of {source:?} basis {basis}"
1186 )));
1187 }
1188 let _guard = self.fork_lock.lock().await;
1189 if self
1190 .dbs
1191 .read()
1192 .unwrap_or_else(std::sync::PoisonError::into_inner)
1193 .contains_key(target)
1194 || self
1195 .store
1196 .get_root(&meta_root_name(target))
1197 .await?
1198 .is_some()
1199 || self.log_backend.exists(target).await
1200 {
1201 return Ok(None);
1202 }
1203 let records = state.log.tx_range_async(0, Some(t + 1)).await?;
1209 let meta = self
1210 .store
1211 .get_root(&meta_root_name(source))
1212 .await?
1213 .ok_or_else(|| NodeError::UnknownDb(source.to_owned()))?;
1214 let log = self.log_backend.open(target, 0).await?;
1219 for record in &records {
1220 log.append_async(record).await?;
1221 }
1222 drop(log);
1223 match self
1224 .store
1225 .cas_root(&meta_root_name(target), None, &meta)
1226 .await
1227 {
1228 Ok(()) => {}
1229 Err(StoreError::CasFailed { .. }) => {
1230 self.log_backend.delete_all(target).await?;
1232 return Ok(None);
1233 }
1234 Err(error) => return Err(error.into()),
1235 }
1236 let state = self.open_db(target).await?;
1237 self.dbs
1238 .write()
1239 .unwrap_or_else(std::sync::PoisonError::into_inner)
1240 .insert(target.to_owned(), state);
1241 Ok(Some(t))
1242 }
1243
1244 pub async fn delete_db(&self, name: &str) -> Result<bool, NodeError> {
1250 let Some(state) = self
1251 .dbs
1252 .write()
1253 .unwrap_or_else(std::sync::PoisonError::into_inner)
1254 .remove(name)
1255 else {
1256 return Ok(false);
1257 };
1258 state.deposed.store(true, Ordering::Release);
1259 self.standby
1260 .write()
1261 .unwrap_or_else(std::sync::PoisonError::into_inner)
1262 .remove(name);
1263 self.store.delete_root(&db_root_name(name)).await?;
1264 self.store.delete_root(&meta_root_name(name)).await?;
1265 self.log_backend.delete_all(name).await?;
1266 Ok(true)
1267 }
1268
1269 #[must_use]
1271 pub fn list_dbs(&self) -> Vec<String> {
1272 let mut names: Vec<String> = self
1273 .dbs
1274 .read()
1275 .unwrap_or_else(std::sync::PoisonError::into_inner)
1276 .keys()
1277 .cloned()
1278 .collect();
1279 names.sort();
1280 names
1281 }
1282
1283 pub async fn gc_deleted(&self) -> Result<u64, NodeError> {
1289 self.gc_deleted_with_retention(self.config.gc_retention)
1290 .await
1291 }
1292
1293 pub async fn gc_deleted_with_retention(&self, retention: Duration) -> Result<u64, NodeError> {
1298 let _gc = self.gc_lock.lock().await;
1299 let mut live = Vec::new();
1300 for root_name in self.store.list_roots("db:").await? {
1301 if let Some(root) = self
1302 .store
1303 .get_root(&root_name)
1304 .await?
1305 .as_deref()
1306 .and_then(DbRoot::decode)
1307 && let Some(roots) = root.roots
1308 {
1309 live.extend(roots);
1310 }
1311 }
1312 let report = mark_and_sweep_retained(
1313 self.store.as_ref(),
1314 live,
1315 |_, bytes| corium_store::index_blob_children(bytes),
1316 retention,
1317 SystemTime::now(),
1318 )
1319 .await?;
1320 self.metrics
1321 .record_gc(report.swept as u64, report.retained as u64);
1322 tracing::info!(
1323 marked = report.marked,
1324 swept = report.swept,
1325 retained = report.retained,
1326 "garbage collection completed"
1327 );
1328 Ok(report.swept as u64)
1329 }
1330
1331 pub async fn transact(
1338 &self,
1339 name: &str,
1340 tx_data: &[u8],
1341 ) -> Result<pb::TransactResponse, NodeError> {
1342 self.transact_at(name, tx_data, None).await
1343 }
1344
1345 pub async fn transact_at(
1355 &self,
1356 name: &str,
1357 tx_data: &[u8],
1358 expected_basis_t: Option<u64>,
1359 ) -> Result<pb::TransactResponse, NodeError> {
1360 let started = Instant::now();
1361 let result = self
1362 .transact_inner(name, tx_data, expected_basis_t)
1363 .instrument(tracing::info_span!("transact", db = name))
1364 .await;
1365 self.metrics.record_tx(started.elapsed(), result.is_ok());
1366 if let Err(error) = &result {
1367 tracing::warn!(%error, "transaction failed");
1368 }
1369 result
1370 }
1371
1372 async fn transact_inner(
1373 &self,
1374 name: &str,
1375 tx_data: &[u8],
1376 expected_basis_t: Option<u64>,
1377 ) -> Result<pb::TransactResponse, NodeError> {
1378 let state = self.db_state(name).await?;
1379 let decoded = codec::decode_edn(tx_data)?;
1380 let forms = decoded
1381 .as_seq()
1382 .ok_or_else(|| NodeError::BadRequest("tx-data must be a vector".into()))?
1383 .to_vec();
1384 let (resp_tx, mut resp_rx) = oneshot::channel();
1390 state
1391 .pending
1392 .lock()
1393 .unwrap_or_else(std::sync::PoisonError::into_inner)
1394 .push_back(CommitRequest {
1395 forms,
1396 expected_basis_t,
1397 resp: resp_tx,
1398 });
1399 let queued = self.metrics.queue_waiter();
1400 loop {
1401 let commit = state.commit.lock().await;
1402 match resp_rx.try_recv() {
1404 Ok(result) => {
1405 drop(commit);
1406 drop(queued);
1407 return result;
1408 }
1409 Err(oneshot::error::TryRecvError::Empty) => {}
1410 Err(oneshot::error::TryRecvError::Closed) => {
1411 drop(commit);
1412 drop(queued);
1413 return Err(NodeError::GroupCommit("commit response dropped".into()));
1414 }
1415 }
1416 self.flush_commit_batch(&state).await;
1418 drop(commit);
1419 match resp_rx.try_recv() {
1420 Ok(result) => {
1421 drop(queued);
1422 return result;
1423 }
1424 Err(oneshot::error::TryRecvError::Empty) => {}
1427 Err(oneshot::error::TryRecvError::Closed) => {
1428 drop(queued);
1429 return Err(NodeError::GroupCommit("commit response dropped".into()));
1430 }
1431 }
1432 }
1433 }
1434
1435 #[allow(clippy::too_many_lines)]
1444 async fn flush_commit_batch(&self, state: &Arc<DbState>) {
1445 let max_count = self.config.max_commit_batch.max(1);
1446 let max_bytes = self.config.max_commit_batch_bytes;
1447
1448 let mut batch: VecDeque<CommitRequest> = {
1449 let mut pending = state
1450 .pending
1451 .lock()
1452 .unwrap_or_else(std::sync::PoisonError::into_inner);
1453 std::mem::take(&mut *pending)
1454 };
1455 if batch.is_empty() {
1456 return;
1457 }
1458 let now_ms = now_unix_ms();
1467 let mut cursor = state.transactor.batch_cursor();
1468 let mut resps: Vec<oneshot::Sender<Result<pb::TransactResponse, NodeError>>> = Vec::new();
1469 let mut prepared: Vec<Prepared> = Vec::new();
1470 let mut batch_bytes: usize = 0;
1471 let mut measure = Vec::new();
1472 let mut naming_changed = false;
1473 while let Some(request) = batch.pop_front() {
1474 if let Some(expected) = request.expected_basis_t {
1475 let actual = cursor.db().basis_t();
1476 if actual != expected {
1477 let _ = request
1478 .resp
1479 .send(Err(NodeError::BasisMismatch { expected, actual }));
1480 continue;
1481 }
1482 }
1483 let forms = if let Some(expander) = &self.config.tx_fn_expander {
1487 let expander = Arc::clone(expander);
1488 let db = cursor.db().clone();
1489 let forms = request.forms;
1490 match tokio::task::spawn_blocking(move || expander.expand(&db, forms)).await {
1491 Ok(Ok(forms)) => forms,
1492 Ok(Err(message)) => {
1493 let _ = request.resp.send(Err(NodeError::BadRequest(message)));
1494 continue;
1495 }
1496 Err(error) => {
1497 let _ = request.resp.send(Err(NodeError::BadRequest(format!(
1498 "expander task failed: {error}"
1499 ))));
1500 continue;
1501 }
1502 }
1503 } else {
1504 request.forms
1505 };
1506 let (items, this_changed) = {
1509 let mut naming = state
1510 .naming
1511 .lock()
1512 .unwrap_or_else(std::sync::PoisonError::into_inner);
1513 let before = naming.interner.len();
1514 match tx_items_from_edn(cursor.db(), &mut naming.interner, &forms) {
1515 Ok(items) => (items, naming.interner.len() > before),
1516 Err(error) => {
1517 drop(naming);
1518 let _ = request.resp.send(Err(error.into()));
1519 continue;
1520 }
1521 }
1522 };
1523 match cursor.prepare(items, now_ms) {
1524 Ok(prep) => {
1525 measure.clear();
1526 let _ = corium_log::append_framed_record(&mut measure, &prep.record);
1527 batch_bytes += measure.len();
1528 resps.push(request.resp);
1529 prepared.push(prep);
1530 }
1531 Err(error) => {
1532 let _ = request.resp.send(Err(NodeError::Transact(error.into())));
1533 continue;
1534 }
1535 }
1536 if this_changed {
1537 naming_changed = true;
1538 break;
1539 }
1540 if prepared.len() >= max_count || batch_bytes >= max_bytes {
1544 break;
1545 }
1546 }
1547 if !batch.is_empty() {
1549 let mut pending = state
1550 .pending
1551 .lock()
1552 .unwrap_or_else(std::sync::PoisonError::into_inner);
1553 while let Some(request) = batch.pop_back() {
1554 pending.push_front(request);
1555 }
1556 }
1557 if prepared.is_empty() {
1558 return;
1559 }
1560 let interner = {
1563 let naming = state
1564 .naming
1565 .lock()
1566 .unwrap_or_else(std::sync::PoisonError::into_inner);
1567 naming.interner.clone()
1568 };
1569 let changed_idents = if naming_changed {
1570 if let Err(error) = state.check_lease(self.store.as_ref()).await {
1574 if matches!(error, NodeError::Deposed(_)) {
1575 self.depose(state, "write lease lost before metadata publish");
1576 }
1577 for resp in resps {
1578 let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1579 }
1580 return;
1581 }
1582 let (idents, schema) = {
1583 let naming = state
1584 .naming
1585 .lock()
1586 .unwrap_or_else(std::sync::PoisonError::into_inner);
1587 (naming.idents.clone(), naming.schema.clone())
1588 };
1589 let meta = codec::encode_metadata(&schema, &idents, &interner);
1592 loop {
1593 let cas = match self.store.get_root(&meta_root_name(&state.name)).await {
1594 Ok(current) => {
1595 self.store
1596 .cas_root(&meta_root_name(&state.name), current.as_deref(), &meta)
1597 .await
1598 }
1599 Err(error) => Err(error),
1600 };
1601 match cas {
1602 Ok(()) => break,
1603 Err(StoreError::CasFailed { .. }) => {}
1604 Err(error) => {
1605 let error = NodeError::Store(error);
1606 for resp in resps {
1607 let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1608 }
1609 return;
1610 }
1611 }
1612 }
1613 Some(idents)
1614 } else {
1615 None
1616 };
1617 let records: Vec<TxRecord> = prepared.iter().map(|prep| prep.record.clone()).collect();
1619 if let Err(error) = state.log.append_batch_async(&records).await {
1620 let error = NodeError::Log(error);
1621 for resp in resps {
1622 let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1623 }
1624 return;
1625 }
1626 let reports = state.transactor.install_batch(cursor, prepared);
1632 if let Some(idents) = changed_idents {
1633 state.transactor.update_naming(idents, interner.clone());
1634 }
1635 if let Err(error) = state.check_lease(self.store.as_ref()).await {
1644 if matches!(error, NodeError::Deposed(_)) {
1645 self.depose(state, "write lease lost after durable append");
1646 }
1647 for resp in resps {
1648 let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1649 }
1650 return;
1651 }
1652 let mut last_t = 0;
1653 for (resp, report) in resps.into_iter().zip(reports) {
1654 let t = report.db_after.basis_t();
1655 last_t = last_t.max(t);
1656 let datoms = match codec::encode_datoms(&report.tx.datoms, &interner) {
1657 Ok(datoms) => datoms,
1658 Err(error) => {
1659 let _ = resp.send(Err(NodeError::Codec(error)));
1660 continue;
1661 }
1662 };
1663 let tempids = codec::encode_edn(&Edn::Map(
1664 report
1665 .tx
1666 .tempids
1667 .iter()
1668 .map(|(tempid, eid)| {
1669 (
1670 Edn::Str(tempid.clone()),
1671 Edn::Long(i64::try_from(eid.raw()).unwrap_or(i64::MAX)),
1672 )
1673 })
1674 .collect(),
1675 ));
1676 let _ = state
1677 .broadcast
1678 .send(pb::subscribe_item::Item::Report(pb::TxReport {
1679 t,
1680 tx_instant: report.tx_instant,
1681 datoms: datoms.clone(),
1682 }));
1683 let _ = resp.send(Ok(pb::TransactResponse {
1684 basis_before: report.db_before.basis_t(),
1685 basis_t: t,
1686 tx_instant: report.tx_instant,
1687 tempids,
1688 tx_data: datoms,
1689 }));
1690 }
1691 if last_t > 0 {
1692 let _ = state.basis.send(last_t);
1693 }
1694 }
1695
1696 pub async fn status(&self, name: &str) -> Result<pb::StatusResponse, NodeError> {
1701 let state = self.db_state(name).await?;
1702 let db = state.db();
1703 let counts = db.stats();
1704 let held = state.lease();
1705 let metrics = self.metrics.snapshot();
1706 Ok(pb::StatusResponse {
1707 basis_t: db.basis_t(),
1708 index_basis_t: state.index_basis(),
1709 lease_owner: held.owner,
1710 lease_version: held.version,
1711 lease_expires_unix_ms: held.expires_unix_ms,
1712 datom_count: counts.datoms as u64,
1713 entity_count: counts.entities as u64,
1714 attribute_count: counts.attributes as u64,
1715 transaction_count: metrics.tx_total,
1716 transaction_failure_count: metrics.tx_failed,
1717 transaction_queue_depth: metrics.queue_depth,
1718 index_lag: db.basis_t().saturating_sub(state.index_basis()),
1719 indexing_runs: metrics.index_runs,
1720 gc_runs: metrics.gc_runs,
1721 gc_swept_blobs: metrics.gc_swept,
1722 lease_owner_endpoint: held.endpoint,
1723 })
1724 }
1725
1726 pub async fn backup_info(&self, name: &str) -> Result<pb::GetStorageInfoResponse, NodeError> {
1734 let state = self.db_state(name).await?;
1735 let _commit = state.commit.lock().await;
1739 state.check_lease(self.store.as_ref()).await?;
1740 let basis_t = state.db().basis_t();
1741 let storage = self
1742 .config
1743 .store
1744 .connection_info(&self.config.data_dir)
1745 .map_err(NodeError::BadRequest)?;
1746 Ok(pb::GetStorageInfoResponse {
1747 basis_t,
1748 storage: Some(storage),
1749 })
1750 }
1751
1752 pub async fn release_leases(&self) {
1757 let states: Vec<Arc<DbState>> = self
1758 .dbs
1759 .write()
1760 .unwrap_or_else(std::sync::PoisonError::into_inner)
1761 .drain()
1762 .map(|(_, state)| state)
1763 .collect();
1764 for state in states {
1765 state.deposed.store(true, Ordering::Release);
1766 if let Err(error) =
1767 lease::release(self.store.as_ref(), &state.name, &state.lease()).await
1768 {
1769 tracing::warn!(db = %state.name, %error, "lease release failed at shutdown");
1770 }
1771 }
1772 }
1773
1774 pub async fn sync(&self, name: &str, t: u64) -> Result<u64, NodeError> {
1779 let state = self.db_state(name).await?;
1780 let mut basis = state.basis_watch();
1781 let target = if t == 0 { *basis.borrow() } else { t };
1782 loop {
1783 let current = *basis.borrow();
1784 if current >= target {
1785 return Ok(current);
1786 }
1787 if basis.changed().await.is_err() {
1788 return Ok(*basis.borrow());
1789 }
1790 }
1791 }
1792}
1793
1794#[cfg(test)]
1795mod tests {
1796 use super::IndexPolicy;
1797 use std::time::Duration;
1798
1799 fn pacing(interval_ms: u64, backoff: u32, threshold: u64, deadline_ms: u64) -> IndexPolicy {
1800 IndexPolicy {
1801 interval: Duration::from_millis(interval_ms),
1802 backoff,
1803 tail_threshold: threshold,
1804 tail_deadline: Duration::from_millis(deadline_ms),
1805 }
1806 }
1807
1808 #[test]
1809 fn base_interval_gates_publication() {
1810 let pacing = pacing(100, 4, 0, 60_000);
1811 assert!(!pacing.due(Duration::from_millis(99), Duration::ZERO, None));
1812 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1813 }
1814
1815 #[test]
1816 fn backoff_stretches_the_floor_past_the_interval() {
1817 let pacing = pacing(100, 4, 0, 60_000);
1818 let last = Duration::from_millis(300);
1819 assert!(!pacing.due(Duration::from_millis(1_199), last, Some(10)));
1820 assert!(pacing.due(Duration::from_millis(1_200), last, Some(10)));
1821 }
1822
1823 #[test]
1824 fn zero_backoff_keeps_the_base_interval() {
1825 let pacing = pacing(100, 0, 0, 60_000);
1826 assert!(pacing.due(
1827 Duration::from_millis(100),
1828 Duration::from_secs(30),
1829 Some(10)
1830 ));
1831 }
1832
1833 #[test]
1834 fn fast_publications_leave_the_interval_untouched() {
1835 let pacing = pacing(5_000, 4, 0, 60_000);
1836 assert!(pacing.due(Duration::from_secs(5), Duration::from_millis(3), Some(1)));
1837 }
1838
1839 #[test]
1840 fn small_tail_defers_until_the_deadline() {
1841 let pacing = pacing(100, 4, 1_000, 60_000);
1842 assert!(!pacing.due(Duration::from_secs(30), Duration::ZERO, Some(999)));
1843 assert!(pacing.due(Duration::from_secs(60), Duration::ZERO, Some(999)));
1844 }
1845
1846 #[test]
1847 fn tail_at_threshold_publishes_at_base_pacing() {
1848 let pacing = pacing(100, 4, 1_000, 60_000);
1849 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, Some(1_000)));
1850 }
1851
1852 #[test]
1853 fn unknown_tail_publishes_at_base_pacing() {
1854 let pacing = pacing(100, 4, 1_000, 60_000);
1855 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1856 }
1857
1858 #[test]
1859 fn deadline_never_overrides_the_backoff_floor() {
1860 let pacing = pacing(100, 4, 1_000, 200);
1861 let last = Duration::from_millis(300);
1862 assert!(!pacing.due(Duration::from_millis(400), last, Some(1)));
1863 assert!(pacing.due(Duration::from_millis(1_200), last, Some(1)));
1864 }
1865}