1use std::collections::{BTreeSet, HashMap};
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, 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, 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 tx_fn_expander: Option<Arc<dyn TxFnExpander>>,
89}
90
91impl std::fmt::Debug for NodeConfig {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.debug_struct("NodeConfig")
94 .field("store", &self.store)
95 .field("data_dir", &self.data_dir)
96 .field("owner", &self.owner)
97 .field("lease_ttl_ms", &self.lease_ttl_ms)
98 .field("lease_wait_ms", &self.lease_wait_ms)
99 .field("ha", &self.ha)
100 .field("advertise", &self.advertise)
101 .field("index_interval", &self.index_interval)
102 .field("index_backoff", &self.index_backoff)
103 .field("index_tail_threshold", &self.index_tail_threshold)
104 .field("index_tail_deadline", &self.index_tail_deadline)
105 .field("heartbeat_interval", &self.heartbeat_interval)
106 .field("gc_interval", &self.gc_interval)
107 .field("gc_retention", &self.gc_retention)
108 .field("tx_fn_expander", &self.tx_fn_expander.is_some())
109 .finish()
110 }
111}
112
113impl NodeConfig {
114 #[must_use]
116 pub fn new(data_dir: PathBuf) -> Self {
117 Self {
118 store: StoreSpec::Fs,
119 data_dir,
120 owner: format!(
121 "transactor-{}",
122 std::env::var("HOSTNAME").unwrap_or_else(|_| "local".into())
123 ),
124 lease_ttl_ms: 5_000,
125 lease_wait_ms: 15_000,
126 ha: false,
127 advertise: None,
128 index_interval: Duration::from_secs(5),
129 index_backoff: 4,
130 index_tail_threshold: 0,
131 index_tail_deadline: Duration::from_secs(60),
132 heartbeat_interval: Duration::from_secs(10),
133 gc_interval: Some(Duration::from_secs(60 * 60)),
134 gc_retention: Duration::from_secs(72 * 60 * 60),
135 #[cfg(feature = "cljrs")]
136 tx_fn_expander: Some(Arc::new(crate::txfn::DbFnExpander::default())),
137 #[cfg(not(feature = "cljrs"))]
138 tx_fn_expander: None,
139 }
140 }
141}
142
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub struct IndexPolicy {
157 pub interval: Duration,
159 pub backoff: u32,
162 pub tail_threshold: u64,
165 pub tail_deadline: Duration,
168}
169
170#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
172pub struct IndexPolicyUpdate {
173 pub interval: Option<Duration>,
175 pub backoff: Option<u32>,
177 pub tail_threshold: Option<u64>,
179 pub tail_deadline: Option<Duration>,
181}
182
183impl IndexPolicy {
184 fn from_config(config: &NodeConfig) -> Self {
185 Self {
186 interval: config.index_interval,
187 backoff: config.index_backoff,
188 tail_threshold: config.index_tail_threshold,
189 tail_deadline: config.index_tail_deadline,
190 }
191 }
192
193 fn apply(&mut self, update: IndexPolicyUpdate) {
194 if let Some(interval) = update.interval {
195 self.interval = interval;
196 }
197 if let Some(backoff) = update.backoff {
198 self.backoff = backoff;
199 }
200 if let Some(tail_threshold) = update.tail_threshold {
201 self.tail_threshold = tail_threshold;
202 }
203 if let Some(tail_deadline) = update.tail_deadline {
204 self.tail_deadline = tail_deadline;
205 }
206 }
207
208 fn due(&self, since_publish: Duration, last_duration: Duration, pending: Option<u64>) -> bool {
215 let floor = self
216 .interval
217 .max(last_duration.saturating_mul(self.backoff));
218 if since_publish < floor {
219 return false;
220 }
221 match pending {
222 Some(pending) if pending < self.tail_threshold => since_publish >= self.tail_deadline,
223 _ => true,
224 }
225 }
226}
227
228#[derive(Debug, Error)]
230pub enum NodeError {
231 #[error("unknown database {0:?}")]
233 UnknownDb(String),
234 #[error("invalid database name {0:?}")]
236 InvalidName(String),
237 #[error("storage format {found} is newer than supported format {supported}")]
239 UnsupportedFormat {
240 found: u32,
242 supported: u32,
244 },
245 #[error("deposed: write lease for {0:?} is held elsewhere")]
247 Deposed(String),
248 #[error("standby for {db:?}: lease held by {owner} at {endpoint:?}")]
251 Standby {
252 db: String,
254 owner: String,
256 endpoint: String,
258 },
259 #[error(transparent)]
261 Codec(#[from] CodecError),
262 #[error(transparent)]
264 TxForm(#[from] TxFormError),
265 #[error(transparent)]
267 SchemaForm(#[from] SchemaFormError),
268 #[error(transparent)]
270 Transact(#[from] TransactError),
271 #[error(transparent)]
273 Store(#[from] StoreError),
274 #[error(transparent)]
276 Log(#[from] LogError),
277 #[error(transparent)]
279 Lease(#[from] LeaseError),
280 #[error("bad request: {0}")]
282 BadRequest(String),
283}
284
285struct Naming {
286 schema: Schema,
287 idents: Idents,
288 interner: KeywordInterner,
289}
290
291pub struct DbState {
293 name: String,
294 transactor: EmbeddedTransactor,
295 log: Arc<dyn TransactionLog>,
296 naming: Mutex<Naming>,
297 commit: tokio::sync::Mutex<()>,
298 broadcast: broadcast::Sender<pb::subscribe_item::Item>,
299 basis: watch::Sender<u64>,
300 index_basis: AtomicU64,
301 index_policy: Mutex<IndexPolicy>,
302 held_lease: Mutex<Lease>,
303 deposed: AtomicBool,
304}
305
306impl DbState {
307 #[must_use]
309 pub fn name(&self) -> &str {
310 &self.name
311 }
312
313 #[must_use]
315 pub fn db(&self) -> Db {
316 self.transactor.db()
317 }
318
319 #[must_use]
321 pub fn basis_watch(&self) -> watch::Receiver<u64> {
322 self.basis.subscribe()
323 }
324
325 #[must_use]
328 pub fn stream_items(&self) -> broadcast::Receiver<pb::subscribe_item::Item> {
329 self.broadcast.subscribe()
330 }
331
332 #[must_use]
334 pub fn index_basis(&self) -> u64 {
335 self.index_basis.load(Ordering::Acquire)
336 }
337
338 #[must_use]
340 pub fn index_policy(&self) -> IndexPolicy {
341 *self
342 .index_policy
343 .lock()
344 .unwrap_or_else(std::sync::PoisonError::into_inner)
345 }
346
347 #[must_use]
349 pub fn lease(&self) -> Lease {
350 self.held_lease
351 .lock()
352 .unwrap_or_else(std::sync::PoisonError::into_inner)
353 .clone()
354 }
355
356 #[must_use]
359 pub fn handshake_snapshot(&self) -> (Vec<u8>, KeywordInterner) {
360 let naming = self
361 .naming
362 .lock()
363 .unwrap_or_else(std::sync::PoisonError::into_inner);
364 (
365 codec::encode_schema(&naming.schema, &naming.idents),
366 naming.interner.clone(),
367 )
368 }
369
370 pub async fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, NodeError> {
375 Ok(self.log.tx_range_async(start, end).await?)
376 }
377
378 async fn check_lease(&self, store: &dyn RootStore) -> Result<Lease, NodeError> {
381 if self.deposed.load(Ordering::Acquire) {
382 return Err(NodeError::Deposed(self.name.clone()));
383 }
384 let held = self.lease();
385 match lease::verify(store, &self.name, &held).await {
386 Ok(()) => Ok(held),
387 Err(LeaseError::Lost) => {
388 self.deposed.store(true, Ordering::Release);
389 Err(NodeError::Deposed(self.name.clone()))
390 }
391 Err(error) => Err(error.into()),
392 }
393 }
394}
395
396pub struct TransactorNode {
398 config: NodeConfig,
399 store: Arc<NodeStore>,
400 log_backend: LogBackend,
401 dbs: std::sync::RwLock<HashMap<String, Arc<DbState>>>,
402 standby: std::sync::RwLock<BTreeSet<String>>,
405 gc_lock: tokio::sync::Mutex<()>,
406 fork_lock: tokio::sync::Mutex<()>,
409 metrics: Metrics,
410 shutdown: watch::Sender<Option<String>>,
411}
412
413fn now_unix_ms() -> i64 {
414 i64::try_from(
415 SystemTime::now()
416 .duration_since(UNIX_EPOCH)
417 .unwrap_or_default()
418 .as_millis(),
419 )
420 .unwrap_or(i64::MAX)
421}
422
423fn valid_db_name(name: &str) -> bool {
424 !name.is_empty()
425 && name.len() <= 128
426 && name
427 .bytes()
428 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
429}
430
431impl TransactorNode {
432 pub async fn open(config: NodeConfig) -> Result<Arc<Self>, NodeError> {
440 let store = Arc::new(NodeStore::open(&config.store, &config.data_dir).await?);
441 let log_backend = LogBackend::for_spec(&config.store, &config.data_dir, Arc::clone(&store));
442 let node = Arc::new(Self {
443 config,
444 store,
445 log_backend,
446 dbs: std::sync::RwLock::new(HashMap::new()),
447 standby: std::sync::RwLock::new(BTreeSet::new()),
448 gc_lock: tokio::sync::Mutex::new(()),
449 fork_lock: tokio::sync::Mutex::new(()),
450 metrics: Metrics::default(),
451 shutdown: watch::channel(None).0,
452 });
453 let names: Vec<String> = node
454 .store
455 .list_roots("meta:")
456 .await?
457 .into_iter()
458 .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
459 .collect();
460 for name in names {
461 match node.open_db(&name).await {
462 Ok(state) => {
463 node.dbs
464 .write()
465 .unwrap_or_else(std::sync::PoisonError::into_inner)
466 .insert(name, state);
467 }
468 Err(NodeError::Lease(LeaseError::Held { owner, .. })) if node.config.ha => {
469 tracing::info!(db = %name, %owner, "standing by; lease held elsewhere");
470 node.standby
471 .write()
472 .unwrap_or_else(std::sync::PoisonError::into_inner)
473 .insert(name);
474 }
475 Err(error) => return Err(error),
476 }
477 }
478 node.spawn_standby_poller();
479 node.spawn_scheduled_gc();
480 Ok(node)
481 }
482
483 #[must_use]
485 pub fn store(&self) -> &Arc<NodeStore> {
486 &self.store
487 }
488
489 #[must_use]
491 pub fn config(&self) -> &NodeConfig {
492 &self.config
493 }
494
495 #[must_use]
497 pub const fn metrics(&self) -> &Metrics {
498 &self.metrics
499 }
500
501 fn spawn_scheduled_gc(self: &Arc<Self>) {
502 let Some(interval) = self.config.gc_interval else {
503 return;
504 };
505 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
506 return;
509 };
510 let node = Arc::clone(self);
511 runtime.spawn(async move {
512 let mut ticker = tokio::time::interval(interval);
513 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
514 ticker.tick().await;
516 loop {
517 ticker.tick().await;
518 if let Err(error) = node.gc_deleted().await {
519 tracing::warn!(%error, "scheduled garbage collection failed");
520 }
521 }
522 });
523 }
524
525 #[must_use]
527 pub fn shutdown_watch(&self) -> watch::Receiver<Option<String>> {
528 self.shutdown.subscribe()
529 }
530
531 fn depose(&self, state: &DbState, reason: &str) {
535 state.deposed.store(true, Ordering::Release);
536 if self.config.ha {
537 tracing::warn!(db = %state.name, reason, "deposed; returning to standby");
538 self.dbs
539 .write()
540 .unwrap_or_else(std::sync::PoisonError::into_inner)
541 .remove(&state.name);
542 self.standby
543 .write()
544 .unwrap_or_else(std::sync::PoisonError::into_inner)
545 .insert(state.name.clone());
546 } else {
547 let _ = self
548 .shutdown
549 .send(Some(format!("database {:?}: {reason}", state.name)));
550 }
551 }
552
553 fn advertised(&self) -> &str {
554 self.config.advertise.as_deref().unwrap_or("")
555 }
556
557 async fn acquire_lease(&self, name: &str) -> Result<Lease, NodeError> {
561 let deadline = now_unix_ms() + self.config.lease_wait_ms;
562 loop {
563 match lease::acquire(
564 self.store.as_ref(),
565 name,
566 &self.config.owner,
567 self.advertised(),
568 self.config.lease_ttl_ms,
569 now_unix_ms(),
570 )
571 .await
572 {
573 Ok(held) => return Ok(held),
574 Err(LeaseError::Held { .. }) if !self.config.ha && now_unix_ms() < deadline => {
575 tokio::time::sleep(Duration::from_millis(200)).await;
576 }
577 Err(error) => return Err(error.into()),
578 }
579 }
580 }
581
582 async fn open_db(self: &Arc<Self>, name: &str) -> Result<Arc<DbState>, NodeError> {
583 let meta = self
584 .store
585 .get_root(&meta_root_name(name))
586 .await?
587 .ok_or_else(|| NodeError::UnknownDb(name.to_owned()))?;
588 let (schema, idents, interner) = codec::decode_metadata(&meta)?;
589 let root_name = db_root_name(name);
590 let current = self
591 .store
592 .get_root(&root_name)
593 .await?
594 .as_deref()
595 .and_then(DbRoot::decode);
596 if let Some(root) = ¤t
597 && root.format_version > corium_store::FORMAT_VERSION
598 {
599 return Err(NodeError::UnsupportedFormat {
600 found: root.format_version,
601 supported: corium_store::FORMAT_VERSION,
602 });
603 }
604 let held = self.acquire_lease(name).await?;
610 let log = self.log_backend.open(name, held.version).await?;
613 let post_fence = self
614 .store
615 .get_root(&root_name)
616 .await?
617 .as_deref()
618 .and_then(DbRoot::decode);
619 let transactor = self
620 .recover_transactor(name, &schema, &idents, &interner, post_fence.as_ref(), &log)
621 .await?;
622 let basis_t = transactor.db().basis_t();
623 let index_basis = post_fence.map_or(0, |root| root.index_basis_t);
624 let state = Arc::new(DbState {
625 name: name.to_owned(),
626 transactor,
627 log,
628 naming: Mutex::new(Naming {
629 schema,
630 idents,
631 interner,
632 }),
633 commit: tokio::sync::Mutex::new(()),
634 broadcast: broadcast::channel(1024).0,
635 basis: watch::channel(basis_t).0,
636 index_basis: AtomicU64::new(index_basis),
637 index_policy: Mutex::new(IndexPolicy::from_config(&self.config)),
638 held_lease: Mutex::new(held),
639 deposed: AtomicBool::new(false),
640 });
641 self.spawn_maintenance(&state);
642 Ok(state)
643 }
644
645 async fn recover_transactor(
654 &self,
655 name: &str,
656 schema: &Schema,
657 idents: &Idents,
658 interner: &KeywordInterner,
659 root: Option<&DbRoot>,
660 log: &Arc<dyn TransactionLog>,
661 ) -> Result<EmbeddedTransactor, NodeError> {
662 if let Some(root) = root
665 && let Some(roots) = &root.roots
666 && root.next_entity_id != 0
667 {
668 match self
669 .load_current_snapshot(
670 root,
671 &roots[IndexOrder::Eavt as usize],
672 schema,
673 idents,
674 interner,
675 )
676 .await
677 {
678 Ok(snapshot) => {
679 return Ok(EmbeddedTransactor::recover_from_snapshot_async(
680 snapshot,
681 root.next_entity_id,
682 root.last_tx_instant,
683 Arc::clone(log),
684 )
685 .await?);
686 }
687 Err(error) => {
688 tracing::warn!(
689 db = %name,
690 %error,
691 "index-root recovery failed; falling back to full-log replay"
692 );
693 }
694 }
695 }
696 let base = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
697 Ok(EmbeddedTransactor::recover_from_async(base, Arc::clone(log)).await?)
698 }
699
700 async fn load_current_snapshot(
705 &self,
706 root: &DbRoot,
707 eavt: &BlobId,
708 schema: &Schema,
709 idents: &Idents,
710 interner: &KeywordInterner,
711 ) -> Result<Db, StoreError> {
712 let datoms = self
713 .load_index_keys(eavt)
714 .await?
715 .into_iter()
716 .map(|key| Datom::from_key(IndexOrder::Eavt, &key))
717 .collect::<Result<Vec<_>, _>>()
718 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
719 Ok(Db::from_current_snapshot(
720 root.index_basis_t,
721 schema.clone(),
722 idents.clone(),
723 interner.clone(),
724 datoms,
725 ))
726 }
727
728 async fn load_index_keys(&self, id: &BlobId) -> Result<Vec<Vec<u8>>, StoreError> {
731 let blob = self
732 .store
733 .get(id)
734 .await?
735 .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
736 if !is_index_manifest(&blob) {
737 return decode_segment_keys(&blob);
738 }
739 let mut keys = Vec::new();
740 for child in decode_index_manifest(&blob)? {
741 let chunk = self
742 .store
743 .get(&child)
744 .await?
745 .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
746 keys.extend(decode_segment_keys(&chunk)?);
747 }
748 Ok(keys)
749 }
750
751 fn spawn_standby_poller(self: &Arc<Self>) {
757 if !self.config.ha {
758 return;
759 }
760 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
761 return;
762 };
763 let ttl = self.config.lease_ttl_ms;
764 let poll_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
765 let node = Arc::clone(self);
766 runtime.spawn(async move {
767 let mut ticker = tokio::time::interval(poll_every);
768 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
769 loop {
770 ticker.tick().await;
771 if let Err(error) = node.standby_scan().await {
772 tracing::warn!(%error, "standby scan failed");
773 }
774 }
775 });
776 }
777
778 async fn standby_scan(self: &Arc<Self>) -> Result<(), NodeError> {
781 let names: Vec<String> = self
782 .store
783 .list_roots("meta:")
784 .await?
785 .into_iter()
786 .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
787 .collect();
788 {
789 let mut standby = self
790 .standby
791 .write()
792 .unwrap_or_else(std::sync::PoisonError::into_inner);
793 standby.retain(|name| names.contains(name));
794 }
795 for name in names {
796 if self
797 .dbs
798 .read()
799 .unwrap_or_else(std::sync::PoisonError::into_inner)
800 .contains_key(&name)
801 {
802 continue;
803 }
804 match self.open_db(&name).await {
805 Ok(state) => {
806 tracing::info!(db = %name, owner = %self.config.owner, "standby took over write lease");
807 self.standby
808 .write()
809 .unwrap_or_else(std::sync::PoisonError::into_inner)
810 .remove(&name);
811 self.dbs
812 .write()
813 .unwrap_or_else(std::sync::PoisonError::into_inner)
814 .insert(name, state);
815 }
816 Err(NodeError::Lease(LeaseError::Held { .. })) => {
817 self.standby
818 .write()
819 .unwrap_or_else(std::sync::PoisonError::into_inner)
820 .insert(name);
821 }
822 Err(error) => {
823 tracing::warn!(db = %name, %error, "standby takeover attempt failed");
824 }
825 }
826 }
827 Ok(())
828 }
829
830 fn spawn_maintenance(self: &Arc<Self>, state: &Arc<DbState>) {
831 let ttl = self.config.lease_ttl_ms;
832 let renew_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
833 let node = Arc::clone(self);
835 let db = Arc::clone(state);
836 tokio::spawn(async move {
837 let mut ticker = tokio::time::interval(renew_every);
838 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
839 loop {
840 ticker.tick().await;
841 if db.deposed.load(Ordering::Acquire) {
842 return;
843 }
844 let _commit = db.commit.lock().await;
848 let held = db.lease();
849 let name = db.name.clone();
850 let renewed =
851 lease::renew(node.store.as_ref(), &name, &held, ttl, now_unix_ms()).await;
852 match renewed {
853 Ok(renewed) => {
854 *db.held_lease
855 .lock()
856 .unwrap_or_else(std::sync::PoisonError::into_inner) = renewed;
857 }
858 Err(LeaseError::Lost) => {
859 node.depose(&db, "write lease lost");
860 return;
861 }
862 Err(_) => {}
863 }
864 }
865 });
866 self.spawn_indexing(state);
867 let node = Arc::clone(self);
869 let db = Arc::clone(state);
870 tokio::spawn(async move {
871 let mut ticker = tokio::time::interval(node.config.heartbeat_interval);
872 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
873 loop {
874 ticker.tick().await;
875 if db.deposed.load(Ordering::Acquire) {
876 return;
877 }
878 let _ = db
879 .broadcast
880 .send(pb::subscribe_item::Item::Heartbeat(pb::Heartbeat {
881 basis_t: db.db().basis_t(),
882 }));
883 }
884 });
885 }
886
887 fn spawn_indexing(self: &Arc<Self>, state: &Arc<DbState>) {
891 const POLICY_POLL: Duration = Duration::from_secs(1);
895 let node = Arc::clone(self);
896 let db = Arc::clone(state);
897 tokio::spawn(async move {
898 let mut published_at = Instant::now();
899 let mut last_duration = Duration::ZERO;
900 let mut published_len: Option<u64> = None;
901 loop {
902 let policy = db.index_policy();
903 tokio::time::sleep(policy.interval.min(POLICY_POLL)).await;
904 if db.deposed.load(Ordering::Acquire) {
905 return;
906 }
907 let snapshot = db.db();
908 if snapshot.basis_t() <= db.index_basis() {
909 continue;
910 }
911 let recorded_len = u64::try_from(snapshot.recorded_len()).unwrap_or(u64::MAX);
912 let pending = published_len.map(|len| recorded_len.saturating_sub(len));
913 if !policy.due(published_at.elapsed(), last_duration, pending) {
914 continue;
915 }
916 match node.publish_db_indexes(&db).await {
917 Ok((_, duration)) => {
918 last_duration = duration;
919 published_len = Some(recorded_len);
923 }
924 Err(NodeError::Deposed(_)) => return,
925 Err(_) => {}
926 }
927 published_at = Instant::now();
928 }
929 });
930 }
931
932 async fn publish_db_indexes(&self, db: &Arc<DbState>) -> Result<(u64, Duration), NodeError> {
937 let _gc = self.gc_lock.lock().await;
938 let version = db.lease().version;
939 let root_name = db_root_name(&db.name);
940 let started = Instant::now();
941 let published = db
942 .transactor
943 .publish_indexes(self.store.as_ref(), &root_name, version)
944 .await;
945 let duration = started.elapsed();
946 self.metrics.record_index(duration);
947 match published {
948 Ok(root) => {
949 tracing::debug!(db = %db.name, index_basis_t = root.index_basis_t, "published indexes");
950 db.index_basis.store(root.index_basis_t, Ordering::Release);
951 let _ = db
952 .broadcast
953 .send(pb::subscribe_item::Item::IndexBasis(pb::IndexBasis {
954 index_basis_t: root.index_basis_t,
955 }));
956 Ok((root.index_basis_t, duration))
957 }
958 Err(TransactError::Deposed { .. }) => {
959 self.depose(db, "database root fenced by a newer lease");
960 Err(NodeError::Deposed(db.name.clone()))
961 }
962 Err(error) => Err(error.into()),
963 }
964 }
965
966 pub async fn request_index(&self, name: &str) -> Result<u64, NodeError> {
975 let state = self.db_state(name).await?;
976 if state.db().basis_t() <= state.index_basis() {
977 return Ok(state.index_basis());
978 }
979 self.publish_db_indexes(&state)
980 .await
981 .map(|(index_basis_t, _)| index_basis_t)
982 }
983
984 pub async fn set_index_policy(
991 &self,
992 name: &str,
993 update: IndexPolicyUpdate,
994 ) -> Result<IndexPolicy, NodeError> {
995 let state = self.db_state(name).await?;
996 let mut policy = state
997 .index_policy
998 .lock()
999 .unwrap_or_else(std::sync::PoisonError::into_inner);
1000 policy.apply(update);
1001 Ok(*policy)
1002 }
1003
1004 pub async fn db_state(&self, name: &str) -> Result<Arc<DbState>, NodeError> {
1010 if let Some(state) = self
1011 .dbs
1012 .read()
1013 .unwrap_or_else(std::sync::PoisonError::into_inner)
1014 .get(name)
1015 .cloned()
1016 {
1017 return Ok(state);
1018 }
1019 if self.config.ha
1020 && self
1021 .standby
1022 .read()
1023 .unwrap_or_else(std::sync::PoisonError::into_inner)
1024 .contains(name)
1025 {
1026 let root = self
1027 .store
1028 .get_root(&db_root_name(name))
1029 .await?
1030 .as_deref()
1031 .and_then(DbRoot::decode);
1032 return Err(NodeError::Standby {
1033 db: name.to_owned(),
1034 owner: root.as_ref().map(|r| r.owner.clone()).unwrap_or_default(),
1035 endpoint: root.map(|r| r.owner_endpoint).unwrap_or_default(),
1036 });
1037 }
1038 Err(NodeError::UnknownDb(name.to_owned()))
1039 }
1040
1041 #[must_use]
1043 pub fn standby_dbs(&self) -> Vec<String> {
1044 self.standby
1045 .read()
1046 .unwrap_or_else(std::sync::PoisonError::into_inner)
1047 .iter()
1048 .cloned()
1049 .collect()
1050 }
1051
1052 pub async fn create_db(
1058 self: &Arc<Self>,
1059 name: &str,
1060 schema_edn: &[u8],
1061 ) -> Result<bool, NodeError> {
1062 if !valid_db_name(name) {
1063 return Err(NodeError::InvalidName(name.to_owned()));
1064 }
1065 if self
1066 .dbs
1067 .read()
1068 .unwrap_or_else(std::sync::PoisonError::into_inner)
1069 .contains_key(name)
1070 {
1071 return Ok(false);
1072 }
1073 let forms = match codec::decode_edn(schema_edn)? {
1074 Edn::Vector(items) | Edn::List(items) => items,
1075 Edn::Nil => Vec::new(),
1076 other => {
1077 return Err(NodeError::BadRequest(format!(
1078 "schema must be a vector of attribute maps, got {other}"
1079 )));
1080 }
1081 };
1082 let (schema, idents) = schema_from_edn(&forms)?;
1083 let meta = codec::encode_metadata(&schema, &idents, &KeywordInterner::default());
1084 match self
1085 .store
1086 .cas_root(&meta_root_name(name), None, &meta)
1087 .await
1088 {
1089 Ok(()) => {}
1090 Err(StoreError::CasFailed { .. }) => return Ok(false),
1091 Err(error) => return Err(error.into()),
1092 }
1093 let state = self.open_db(name).await?;
1094 self.dbs
1095 .write()
1096 .unwrap_or_else(std::sync::PoisonError::into_inner)
1097 .insert(name.to_owned(), state);
1098 Ok(true)
1099 }
1100
1101 pub async fn fork_db(
1112 self: &Arc<Self>,
1113 source: &str,
1114 target: &str,
1115 as_of_t: u64,
1116 ) -> Result<Option<u64>, NodeError> {
1117 if !valid_db_name(target) {
1118 return Err(NodeError::InvalidName(target.to_owned()));
1119 }
1120 if source == target {
1121 return Err(NodeError::BadRequest(
1122 "fork target must differ from the source".into(),
1123 ));
1124 }
1125 let state = self.db_state(source).await?;
1126 let basis = state.db().basis_t();
1127 let t = if as_of_t == 0 { basis } else { as_of_t };
1128 if t > basis {
1129 return Err(NodeError::BadRequest(format!(
1130 "as-of t {t} is ahead of {source:?} basis {basis}"
1131 )));
1132 }
1133 let _guard = self.fork_lock.lock().await;
1134 if self
1135 .dbs
1136 .read()
1137 .unwrap_or_else(std::sync::PoisonError::into_inner)
1138 .contains_key(target)
1139 || self
1140 .store
1141 .get_root(&meta_root_name(target))
1142 .await?
1143 .is_some()
1144 || self.log_backend.exists(target).await
1145 {
1146 return Ok(None);
1147 }
1148 let records = state.log.tx_range_async(0, Some(t + 1)).await?;
1154 let meta = self
1155 .store
1156 .get_root(&meta_root_name(source))
1157 .await?
1158 .ok_or_else(|| NodeError::UnknownDb(source.to_owned()))?;
1159 let log = self.log_backend.open(target, 0).await?;
1164 for record in &records {
1165 log.append_async(record).await?;
1166 }
1167 drop(log);
1168 match self
1169 .store
1170 .cas_root(&meta_root_name(target), None, &meta)
1171 .await
1172 {
1173 Ok(()) => {}
1174 Err(StoreError::CasFailed { .. }) => {
1175 self.log_backend.delete_all(target).await?;
1177 return Ok(None);
1178 }
1179 Err(error) => return Err(error.into()),
1180 }
1181 let state = self.open_db(target).await?;
1182 self.dbs
1183 .write()
1184 .unwrap_or_else(std::sync::PoisonError::into_inner)
1185 .insert(target.to_owned(), state);
1186 Ok(Some(t))
1187 }
1188
1189 pub async fn delete_db(&self, name: &str) -> Result<bool, NodeError> {
1195 let Some(state) = self
1196 .dbs
1197 .write()
1198 .unwrap_or_else(std::sync::PoisonError::into_inner)
1199 .remove(name)
1200 else {
1201 return Ok(false);
1202 };
1203 state.deposed.store(true, Ordering::Release);
1204 self.standby
1205 .write()
1206 .unwrap_or_else(std::sync::PoisonError::into_inner)
1207 .remove(name);
1208 self.store.delete_root(&db_root_name(name)).await?;
1209 self.store.delete_root(&meta_root_name(name)).await?;
1210 self.log_backend.delete_all(name).await?;
1211 Ok(true)
1212 }
1213
1214 #[must_use]
1216 pub fn list_dbs(&self) -> Vec<String> {
1217 let mut names: Vec<String> = self
1218 .dbs
1219 .read()
1220 .unwrap_or_else(std::sync::PoisonError::into_inner)
1221 .keys()
1222 .cloned()
1223 .collect();
1224 names.sort();
1225 names
1226 }
1227
1228 pub async fn gc_deleted(&self) -> Result<u64, NodeError> {
1234 self.gc_deleted_with_retention(self.config.gc_retention)
1235 .await
1236 }
1237
1238 pub async fn gc_deleted_with_retention(&self, retention: Duration) -> Result<u64, NodeError> {
1243 let _gc = self.gc_lock.lock().await;
1244 let mut live = Vec::new();
1245 for root_name in self.store.list_roots("db:").await? {
1246 if let Some(root) = self
1247 .store
1248 .get_root(&root_name)
1249 .await?
1250 .as_deref()
1251 .and_then(DbRoot::decode)
1252 && let Some(roots) = root.roots
1253 {
1254 live.extend(roots);
1255 }
1256 }
1257 let report = mark_and_sweep_retained(
1258 self.store.as_ref(),
1259 live,
1260 |_, bytes| corium_store::index_blob_children(bytes),
1261 retention,
1262 SystemTime::now(),
1263 )
1264 .await?;
1265 self.metrics
1266 .record_gc(report.swept as u64, report.retained as u64);
1267 tracing::info!(
1268 marked = report.marked,
1269 swept = report.swept,
1270 retained = report.retained,
1271 "garbage collection completed"
1272 );
1273 Ok(report.swept as u64)
1274 }
1275
1276 pub async fn transact(
1283 &self,
1284 name: &str,
1285 tx_data: &[u8],
1286 ) -> Result<pb::TransactResponse, NodeError> {
1287 let started = Instant::now();
1288 let result = self
1289 .transact_inner(name, tx_data)
1290 .instrument(tracing::info_span!("transact", db = name))
1291 .await;
1292 self.metrics.record_tx(started.elapsed(), result.is_ok());
1293 if let Err(error) = &result {
1294 tracing::warn!(%error, "transaction failed");
1295 }
1296 result
1297 }
1298
1299 async fn transact_inner(
1300 &self,
1301 name: &str,
1302 tx_data: &[u8],
1303 ) -> Result<pb::TransactResponse, NodeError> {
1304 let state = self.db_state(name).await?;
1305 let decoded = codec::decode_edn(tx_data)?;
1306 let forms = decoded
1307 .as_seq()
1308 .ok_or_else(|| NodeError::BadRequest("tx-data must be a vector".into()))?
1309 .to_vec();
1310 let queued = self.metrics.queue_waiter();
1311 let commit = state.commit.lock().await;
1312 drop(queued);
1313 let _commit = commit;
1314 state.check_lease(self.store.as_ref()).await?;
1315 let forms = if let Some(expander) = &self.config.tx_fn_expander {
1320 let expander = Arc::clone(expander);
1321 let db = state.transactor.db();
1322 tokio::task::spawn_blocking(move || expander.expand(&db, forms))
1323 .await
1324 .map_err(|error| NodeError::BadRequest(format!("expander task failed: {error}")))?
1325 .map_err(NodeError::BadRequest)?
1326 } else {
1327 forms
1328 };
1329 let (items, naming_changed, idents, interner, schema) = {
1331 let mut naming = state
1332 .naming
1333 .lock()
1334 .unwrap_or_else(std::sync::PoisonError::into_inner);
1335 let db = state.transactor.db();
1336 let before = naming.interner.len();
1337 let items = tx_items_from_edn(&db, &mut naming.interner, &forms)?;
1338 (
1339 items,
1340 naming.interner.len() > before,
1341 naming.idents.clone(),
1342 naming.interner.clone(),
1343 naming.schema.clone(),
1344 )
1345 };
1346 if naming_changed {
1347 let meta = codec::encode_metadata(&schema, &idents, &interner);
1350 loop {
1351 let current = self.store.get_root(&meta_root_name(name)).await?;
1352 match self
1353 .store
1354 .cas_root(&meta_root_name(name), current.as_deref(), &meta)
1355 .await
1356 {
1357 Ok(()) => break,
1358 Err(StoreError::CasFailed { .. }) => {}
1359 Err(error) => return Err(error.into()),
1360 }
1361 }
1362 state.transactor.update_naming(idents, interner.clone());
1363 }
1364 let report = state.transactor.transact_async(items).await?;
1365 if let Err(error) = state.check_lease(self.store.as_ref()).await {
1372 if matches!(error, NodeError::Deposed(_)) {
1373 self.depose(&state, "write lease lost after durable append");
1374 }
1375 return Err(error);
1379 }
1380 let t = report.db_after.basis_t();
1381 let datoms = codec::encode_datoms(&report.tx.datoms, &interner)?;
1382 let tempids = codec::encode_edn(&Edn::Map(
1383 report
1384 .tx
1385 .tempids
1386 .iter()
1387 .map(|(tempid, eid)| {
1388 (
1389 Edn::Str(tempid.clone()),
1390 Edn::Long(i64::try_from(eid.raw()).unwrap_or(i64::MAX)),
1391 )
1392 })
1393 .collect(),
1394 ));
1395 let _ = state
1396 .broadcast
1397 .send(pb::subscribe_item::Item::Report(pb::TxReport {
1398 t,
1399 tx_instant: report.tx_instant,
1400 datoms: datoms.clone(),
1401 }));
1402 let _ = state.basis.send(t);
1403 Ok(pb::TransactResponse {
1404 basis_before: report.db_before.basis_t(),
1405 basis_t: t,
1406 tx_instant: report.tx_instant,
1407 tempids,
1408 tx_data: datoms,
1409 })
1410 }
1411
1412 pub async fn status(&self, name: &str) -> Result<pb::StatusResponse, NodeError> {
1417 let state = self.db_state(name).await?;
1418 let db = state.db();
1419 let counts = db.stats();
1420 let held = state.lease();
1421 let metrics = self.metrics.snapshot();
1422 Ok(pb::StatusResponse {
1423 basis_t: db.basis_t(),
1424 index_basis_t: state.index_basis(),
1425 lease_owner: held.owner,
1426 lease_version: held.version,
1427 lease_expires_unix_ms: held.expires_unix_ms,
1428 datom_count: counts.datoms as u64,
1429 entity_count: counts.entities as u64,
1430 attribute_count: counts.attributes as u64,
1431 transaction_count: metrics.tx_total,
1432 transaction_failure_count: metrics.tx_failed,
1433 transaction_queue_depth: metrics.queue_depth,
1434 index_lag: db.basis_t().saturating_sub(state.index_basis()),
1435 indexing_runs: metrics.index_runs,
1436 gc_runs: metrics.gc_runs,
1437 gc_swept_blobs: metrics.gc_swept,
1438 lease_owner_endpoint: held.endpoint,
1439 })
1440 }
1441
1442 pub async fn release_leases(&self) {
1447 let states: Vec<Arc<DbState>> = self
1448 .dbs
1449 .write()
1450 .unwrap_or_else(std::sync::PoisonError::into_inner)
1451 .drain()
1452 .map(|(_, state)| state)
1453 .collect();
1454 for state in states {
1455 state.deposed.store(true, Ordering::Release);
1456 if let Err(error) =
1457 lease::release(self.store.as_ref(), &state.name, &state.lease()).await
1458 {
1459 tracing::warn!(db = %state.name, %error, "lease release failed at shutdown");
1460 }
1461 }
1462 }
1463
1464 pub async fn sync(&self, name: &str, t: u64) -> Result<u64, NodeError> {
1469 let state = self.db_state(name).await?;
1470 let mut basis = state.basis_watch();
1471 let target = if t == 0 { *basis.borrow() } else { t };
1472 loop {
1473 let current = *basis.borrow();
1474 if current >= target {
1475 return Ok(current);
1476 }
1477 if basis.changed().await.is_err() {
1478 return Ok(*basis.borrow());
1479 }
1480 }
1481 }
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486 use super::IndexPolicy;
1487 use std::time::Duration;
1488
1489 fn pacing(interval_ms: u64, backoff: u32, threshold: u64, deadline_ms: u64) -> IndexPolicy {
1490 IndexPolicy {
1491 interval: Duration::from_millis(interval_ms),
1492 backoff,
1493 tail_threshold: threshold,
1494 tail_deadline: Duration::from_millis(deadline_ms),
1495 }
1496 }
1497
1498 #[test]
1499 fn base_interval_gates_publication() {
1500 let pacing = pacing(100, 4, 0, 60_000);
1501 assert!(!pacing.due(Duration::from_millis(99), Duration::ZERO, None));
1502 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1503 }
1504
1505 #[test]
1506 fn backoff_stretches_the_floor_past_the_interval() {
1507 let pacing = pacing(100, 4, 0, 60_000);
1508 let last = Duration::from_millis(300);
1509 assert!(!pacing.due(Duration::from_millis(1_199), last, Some(10)));
1510 assert!(pacing.due(Duration::from_millis(1_200), last, Some(10)));
1511 }
1512
1513 #[test]
1514 fn zero_backoff_keeps_the_base_interval() {
1515 let pacing = pacing(100, 0, 0, 60_000);
1516 assert!(pacing.due(
1517 Duration::from_millis(100),
1518 Duration::from_secs(30),
1519 Some(10)
1520 ));
1521 }
1522
1523 #[test]
1524 fn fast_publications_leave_the_interval_untouched() {
1525 let pacing = pacing(5_000, 4, 0, 60_000);
1526 assert!(pacing.due(Duration::from_secs(5), Duration::from_millis(3), Some(1)));
1527 }
1528
1529 #[test]
1530 fn small_tail_defers_until_the_deadline() {
1531 let pacing = pacing(100, 4, 1_000, 60_000);
1532 assert!(!pacing.due(Duration::from_secs(30), Duration::ZERO, Some(999)));
1533 assert!(pacing.due(Duration::from_secs(60), Duration::ZERO, Some(999)));
1534 }
1535
1536 #[test]
1537 fn tail_at_threshold_publishes_at_base_pacing() {
1538 let pacing = pacing(100, 4, 1_000, 60_000);
1539 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, Some(1_000)));
1540 }
1541
1542 #[test]
1543 fn unknown_tail_publishes_at_base_pacing() {
1544 let pacing = pacing(100, 4, 1_000, 60_000);
1545 assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1546 }
1547
1548 #[test]
1549 fn deadline_never_overrides_the_backoff_floor() {
1550 let pacing = pacing(100, 4, 1_000, 200);
1551 let last = Duration::from_millis(300);
1552 assert!(!pacing.due(Duration::from_millis(400), last, Some(1)));
1553 assert!(pacing.due(Duration::from_millis(1_200), last, Some(1)));
1554 }
1555}