Skip to main content

corium_transactor/
node.rs

1//! The transactor as a process: multi-database state, durable naming,
2//! lease acquisition/renewal, background indexing, tx-report fan-out, and
3//! high-availability standby takeover.
4
5use 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
32/// Expands user database-function invocations in boundary EDN transaction
33/// forms before native conversion. The built-in implementation is
34/// [`crate::txfn::DbFnExpander`] on the bounded `cljrs-tx` runtime (feature
35/// `cljrs`, on by default, ADR-0008); embedders may inject their own.
36pub trait TxFnExpander: Send + Sync {
37    /// Rewrites `forms` with every `[:my/fn arg…]` invocation replaced by
38    /// the function's returned tx-data (recursively).
39    ///
40    /// # Errors
41    /// Returns a display message when a function is missing, rejected by
42    /// the sandbox, fails, or exceeds its budget; the transaction aborts.
43    fn expand(&self, db: &Db, forms: Vec<Edn>) -> Result<Vec<Edn>, String>;
44}
45
46/// Node process configuration.
47#[derive(Clone)]
48pub struct NodeConfig {
49    /// Storage-service backend for blobs and roots (`mem`, `fs`, or Turso).
50    pub store: StoreSpec,
51    /// Data directory holding the filesystem blob/root store (for the `fs`
52    /// backend) and the transaction logs (for every non-`mem` backend).
53    pub data_dir: PathBuf,
54    /// Stable owner identity for lease records.
55    pub owner: String,
56    /// Lease time-to-live in milliseconds.
57    pub lease_ttl_ms: i64,
58    /// How long to wait for a held lease to expire before giving up.
59    pub lease_wait_ms: i64,
60    /// High-availability mode: when another owner holds a database's lease,
61    /// stand by and take over on expiry instead of failing startup, and on
62    /// depose return to standby instead of shutting the process down.
63    pub ha: bool,
64    /// Client endpoint advertised in the lease for peer lease-holder
65    /// rediscovery (e.g. `http://transactor-a:4334`).
66    pub advertise: Option<String>,
67    /// Interval between background index publications.
68    pub index_interval: Duration,
69    /// Minimum wait before the next index publication, as a multiple of the
70    /// previous publication's duration. Publications currently rewrite every
71    /// index in full, so this stretches the effective interval as the
72    /// database grows, bounding the share of time and storage bandwidth
73    /// spent republishing to at most `1/(1+n)`; 0 disables the backoff.
74    pub index_backoff: u32,
75    /// Pending log-tail growth (recorded datoms) below which a due
76    /// publication is deferred, so trickle writes coalesce instead of
77    /// rewriting every index; 0 publishes any pending work.
78    pub index_tail_threshold: u64,
79    /// Longest a pending below-threshold tail may defer publication.
80    pub index_tail_deadline: Duration,
81    /// Interval between heartbeats on subscription streams.
82    pub heartbeat_interval: Duration,
83    /// Interval between scheduled garbage-collection duties; `None` disables it.
84    pub gc_interval: Option<Duration>,
85    /// Minimum age of an unreachable blob before scheduled/manual online GC.
86    pub gc_retention: Duration,
87    /// Optional database-function expander (`:db/fn` support).
88    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    /// Sensible defaults for a data directory.
115    #[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/// Pacing policy for one database's background indexing job.
144///
145/// A publication is due when the adaptive floor has elapsed — the base
146/// interval stretched by a multiple of the previous publication's duration,
147/// which bounds the indexing duty cycle as full republication gets slower —
148/// and the pending log tail is either large enough to be worth rewriting
149/// every index or old enough that deferring it further would leave cold
150/// readers and backups too far behind.
151///
152/// Every database starts from the node's [`NodeConfig`] pacing fields; the
153/// catalog `SetIndexPolicy` RPC (or
154/// [`TransactorNode::set_index_policy`]) overrides it at runtime.
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub struct IndexPolicy {
157    /// Base interval between publications ([`NodeConfig::index_interval`]).
158    pub interval: Duration,
159    /// Duty-cycle multiplier on the previous publication's duration
160    /// ([`NodeConfig::index_backoff`]).
161    pub backoff: u32,
162    /// Pending-datom count below which a due publication is deferred
163    /// ([`NodeConfig::index_tail_threshold`]).
164    pub tail_threshold: u64,
165    /// Longest a below-threshold tail may defer publication
166    /// ([`NodeConfig::index_tail_deadline`]).
167    pub tail_deadline: Duration,
168}
169
170/// Partial [`IndexPolicy`] override; `None` fields are left unchanged.
171#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
172pub struct IndexPolicyUpdate {
173    /// New base interval, when set.
174    pub interval: Option<Duration>,
175    /// New duty-cycle multiplier, when set.
176    pub backoff: Option<u32>,
177    /// New pending-datom threshold, when set.
178    pub tail_threshold: Option<u64>,
179    /// New deferral deadline, when set.
180    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    /// Decides whether pending work should publish now. `since_publish` is
209    /// the time since the last publication finished (or the job started),
210    /// `last_duration` how long it took (zero before the first), and
211    /// `pending` the recorded datoms appended since it — `None` until a
212    /// publication in this process establishes a baseline, which publishes
213    /// at base pacing (covers restarting with an unindexed backlog).
214    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/// Node operation failure.
229#[derive(Debug, Error)]
230pub enum NodeError {
231    /// Named database does not exist.
232    #[error("unknown database {0:?}")]
233    UnknownDb(String),
234    /// Database name is not storable.
235    #[error("invalid database name {0:?}")]
236    InvalidName(String),
237    /// Database root uses a storage format newer than this binary.
238    #[error("storage format {found} is newer than supported format {supported}")]
239    UnsupportedFormat {
240        /// Version found in the root.
241        found: u32,
242        /// Newest version understood by this binary.
243        supported: u32,
244    },
245    /// This node no longer holds the write lease.
246    #[error("deposed: write lease for {0:?} is held elsewhere")]
247    Deposed(String),
248    /// This node is a warm standby for the database; the lease holder
249    /// serves it.
250    #[error("standby for {db:?}: lease held by {owner} at {endpoint:?}")]
251    Standby {
252        /// Database name.
253        db: String,
254        /// Current lease owner id (empty when unknown).
255        owner: String,
256        /// Owner's advertised client endpoint (empty when unadvertised).
257        endpoint: String,
258    },
259    /// Payload failed to decode.
260    #[error(transparent)]
261    Codec(#[from] CodecError),
262    /// Transaction forms failed to convert.
263    #[error(transparent)]
264    TxForm(#[from] TxFormError),
265    /// Schema forms failed to convert.
266    #[error(transparent)]
267    SchemaForm(#[from] SchemaFormError),
268    /// Transaction pipeline failure.
269    #[error(transparent)]
270    Transact(#[from] TransactError),
271    /// Store failure.
272    #[error(transparent)]
273    Store(#[from] StoreError),
274    /// Log failure.
275    #[error(transparent)]
276    Log(#[from] LogError),
277    /// Lease failure.
278    #[error(transparent)]
279    Lease(#[from] LeaseError),
280    /// Malformed request.
281    #[error("bad request: {0}")]
282    BadRequest(String),
283}
284
285struct Naming {
286    schema: Schema,
287    idents: Idents,
288    interner: KeywordInterner,
289}
290
291/// Per-database state hosted by a node.
292pub 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    /// Database name.
308    #[must_use]
309    pub fn name(&self) -> &str {
310        &self.name
311    }
312
313    /// Current database value.
314    #[must_use]
315    pub fn db(&self) -> Db {
316        self.transactor.db()
317    }
318
319    /// Watch channel following the commit basis.
320    #[must_use]
321    pub fn basis_watch(&self) -> watch::Receiver<u64> {
322        self.basis.subscribe()
323    }
324
325    /// Subscribes to live stream items (reports, index announcements,
326    /// heartbeats).
327    #[must_use]
328    pub fn stream_items(&self) -> broadcast::Receiver<pb::subscribe_item::Item> {
329        self.broadcast.subscribe()
330    }
331
332    /// Basis of the newest published index root.
333    #[must_use]
334    pub fn index_basis(&self) -> u64 {
335        self.index_basis.load(Ordering::Acquire)
336    }
337
338    /// The indexing pacing policy currently in effect for this database.
339    #[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    /// Currently held lease record.
348    #[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    /// Encoded schema/ident handshake payload plus a consistent basis and
357    /// interner snapshot for backfill encoding.
358    #[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    /// Reads committed records in `[start, end)` from the durable log.
371    ///
372    /// # Errors
373    /// Returns an error when the log cannot be read.
374    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    /// Verifies this node still owns the write lease (identity check on
379    /// the root record; expiry changes from renewals do not matter).
380    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
396/// A running transactor node hosting every database under one data directory.
397pub struct TransactorNode {
398    config: NodeConfig,
399    store: Arc<NodeStore>,
400    log_backend: LogBackend,
401    dbs: std::sync::RwLock<HashMap<String, Arc<DbState>>>,
402    /// Databases this node is standing by for (HA mode): the lease is held
403    /// elsewhere and the standby poller attempts takeover on expiry.
404    standby: std::sync::RwLock<BTreeSet<String>>,
405    gc_lock: tokio::sync::Mutex<()>,
406    /// Serializes forks: two forks to the same target must not interleave
407    /// appends into one target log.
408    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    /// Opens a node over `config.data_dir`, recovering every database found
433    /// there (acquiring its lease, waiting out held leases up to the
434    /// configured bound).
435    ///
436    /// # Errors
437    /// Returns an error when the store cannot be opened or a database cannot
438    /// be recovered.
439    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    /// The node's storage-service backend (blobs + roots).
484    #[must_use]
485    pub fn store(&self) -> &Arc<NodeStore> {
486        &self.store
487    }
488
489    /// Node configuration.
490    #[must_use]
491    pub fn config(&self) -> &NodeConfig {
492        &self.config
493    }
494
495    /// Process observability counters.
496    #[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            // Embedded callers may construct an empty catalog before they
507            // enter a runtime. Process wiring opens nodes inside Tokio.
508            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            // `interval` ticks immediately; scheduled duties should wait a full interval.
515            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    /// Watch channel that reports a shutdown reason when the node deposes.
526    #[must_use]
527    pub fn shutdown_watch(&self) -> watch::Receiver<Option<String>> {
528        self.shutdown.subscribe()
529    }
530
531    /// Deposes a hosted database. In HA mode the database returns to
532    /// standby (the poller re-attempts takeover); otherwise the whole
533    /// process shuts down and a supervisor restart re-acquires or waits.
534    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    /// Acquires the lease for `name`. In HA mode a held lease surfaces
558    /// immediately (the caller stands by); otherwise startup waits it out
559    /// up to the configured bound.
560    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) = &current
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        // Acquisition rewrites the root record under our lease version, so
605        // it doubles as the fence bump: a deposed writer's pending root CAS
606        // now has stale expected bytes and must fail. It also preserves the
607        // published snapshot's recovery hints, so the root we re-read below
608        // carries everything index-root recovery needs.
609        let held = self.acquire_lease(name).await?;
610        // The log tail replay below happens strictly after the fence, so it
611        // observes every record a previous owner could ever have acked.
612        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    /// Builds the recovered transactor for `open_db`.
646    ///
647    /// When the post-fence root publishes a current snapshot with recovery
648    /// hints, recovers from the index root plus the log tail — open time
649    /// proportional to the tail, not the whole history. Any missing hint
650    /// (a pre-recovery root, or a bare fence bump with no snapshot) or a
651    /// failure materializing the snapshot falls back to full-log replay,
652    /// which is always correct because the log is the source of truth.
653    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        // `next_entity_id == 0` is the "no hint" sentinel (see DbRoot); it and
663        // an absent snapshot both rule out the tail-only path.
664        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    /// Materializes the current database value at a published index root from
701    /// its EAVT snapshot — the transactor-side counterpart of the peer's
702    /// bootstrap (`corium-peer`'s `load_current_snapshot`). Only current
703    /// facts are reconstructed; the log tail carries everything since.
704    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    /// Reads one covering index's sorted key stream from the blob store: a
729    /// format-3 manifest's chunks in order, or a pre-format-3 flat blob.
730    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    /// HA standby duty: at the lease-renewal cadence, rediscover databases
752    /// (including ones created on the active after this process started)
753    /// and attempt takeover of any whose lease has lapsed. Takeover is
754    /// ordinary startup — acquire (which fences), replay the log tail,
755    /// serve — per the crash-only design.
756    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    /// One standby pass: refresh the standby set from the catalog and try
779    /// to take over lapsed leases.
780    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        // Lease renewal.
834        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                // Serialize the root update and local held-lease update with
845                // transaction lease checks so they cannot observe different
846                // renewal generations and falsely depose this node.
847                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        // Heartbeats.
868        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    /// Spawns the background indexing job, paced by the database's
888    /// [`IndexPolicy`] (re-read every iteration so runtime overrides apply
889    /// within a poll interval).
890    fn spawn_indexing(self: &Arc<Self>, state: &Arc<DbState>) {
891        // How often the job re-checks work and policy when the configured
892        // interval is longer; runtime policy changes and pending work are
893        // never noticed later than this.
894        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                        // publish_db_indexes snapshots after this loop did,
920                        // so the covered length is at least recorded_len; the
921                        // underestimate only makes the next tail look bigger.
922                        published_len = Some(recorded_len);
923                    }
924                    Err(NodeError::Deposed(_)) => return,
925                    Err(_) => {}
926                }
927                published_at = Instant::now();
928            }
929        });
930    }
931
932    /// Publishes `db`'s covering indexes now, returning the published index
933    /// basis and how long the publication took. Serialized with garbage
934    /// collection; deposes the database when the root is fenced by a newer
935    /// lease.
936    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    /// Publishes indexes for `name` immediately, bypassing the pacing
967    /// policy (the catalog `RequestIndex` RPC). Returns the resulting index
968    /// basis; when the published indexes already cover every committed
969    /// transaction, returns the current index basis without publishing.
970    ///
971    /// # Errors
972    /// Returns [`NodeError`] when the database is unknown, this node is
973    /// deposed or standing by, or publication fails.
974    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    /// Applies per-database indexing-policy overrides at runtime, returning
985    /// the policy now in effect. An empty update reads the current policy.
986    ///
987    /// # Errors
988    /// Returns [`NodeError`] when the database is unknown or served
989    /// elsewhere.
990    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    /// Looks up a hosted database.
1005    ///
1006    /// # Errors
1007    /// Returns [`NodeError::Standby`] when this HA node is standing by for
1008    /// the database, [`NodeError::UnknownDb`] when absent.
1009    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    /// Databases this node currently stands by for (HA mode).
1042    #[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    /// Creates a database with the supplied EDN schema forms; returns
1053    /// `false` when it already exists.
1054    ///
1055    /// # Errors
1056    /// Returns an error for invalid names/schema or store failures.
1057    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    /// Forks `source` into a new database `target` whose state duplicates
1102    /// the source as of transaction `as_of_t` (`0` forks at the current
1103    /// basis). Only the log prefix is copied; the target replays it and
1104    /// publishes its own indexes, while blob segments dedupe by content
1105    /// address. Returns the fork's basis, or `None` when `target` already
1106    /// exists.
1107    ///
1108    /// # Errors
1109    /// Returns an error for an invalid target name, an unknown source, an
1110    /// `as_of_t` ahead of the source's basis, or store/log failures.
1111    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        // Capture the records before the metadata: meta is made durable
1149        // before any record that references it, so a meta read afterwards is
1150        // always a sufficient decode dictionary for the captured prefix.
1151        // Transaction numbers are contiguous from 1, so the prefix through
1152        // `t` is exactly the source's state at that basis.
1153        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        // Write the log under version 0 so it sorts beneath the
1160        // lease-versioned file the target's first open creates, and publish
1161        // meta last — it is the catalog entry, so a crash mid-fork never
1162        // catalogs a target without its log.
1163        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                // Another node claimed the name first; discard our log copy.
1176                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    /// Deletes a database: unhosts it, releases its lease, and removes its
1190    /// roots and log. Blobs remain until [`Self::gc_deleted`].
1191    ///
1192    /// # Errors
1193    /// Returns an error when roots or the log cannot be removed.
1194    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    /// Lists hosted databases.
1215    #[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    /// Sweeps blobs unreachable from any live database root (including
1229    /// everything left behind by deleted databases and superseded indexes).
1230    ///
1231    /// # Errors
1232    /// Returns an error when the store cannot be enumerated or swept.
1233    pub async fn gc_deleted(&self) -> Result<u64, NodeError> {
1234        self.gc_deleted_with_retention(self.config.gc_retention)
1235            .await
1236    }
1237
1238    /// Sweeps unreachable blobs older than the caller-supplied retention.
1239    ///
1240    /// # Errors
1241    /// Returns an error when the store cannot be enumerated or swept.
1242    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    /// Validates, appends, applies, and reports one transaction supplied as
1277    /// composite-encoded EDN transaction forms.
1278    ///
1279    /// # Errors
1280    /// Returns [`NodeError`] for decode/validation failures, lease loss, or
1281    /// storage failures.
1282    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        // Expand user database-function invocations against the
1316        // db-in-transaction (the value under the commit lock) before native
1317        // conversion. The expander blocks up to its budget deadline, so it
1318        // runs off the async workers.
1319        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        // Convert forms, interning any new keyword values.
1330        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            // New keyword names must be durable before the datoms that
1348            // reference them; recovery decodes the log against this meta.
1349            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        // Post-append fence: acknowledge only if ownership was intact after
1366        // the record became durable. A takeover that raced the append will
1367        // have replayed the log *after* rewriting the root record, so a
1368        // record we acked here is provably in the successor's replay, and a
1369        // record we refuse here lands in our version's log file where the
1370        // successor's cutoff discards it (see log-and-transactor.md).
1371        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            // Either way the transaction is not acknowledged; a transient
1376            // store failure here is ambiguous to the caller, exactly like a
1377            // crash between append and reply.
1378            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    /// Current status for a database.
1413    ///
1414    /// # Errors
1415    /// Returns [`NodeError::UnknownDb`] when absent.
1416    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    /// Releases every held write lease (graceful shutdown): the record is
1443    /// expired in place so a standby's next poll takes over immediately
1444    /// instead of waiting out the TTL. Hosted databases stop accepting
1445    /// work first, so nothing commits after its lease is gone.
1446    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    /// Waits until the database basis reaches `t`, returning the basis seen.
1465    ///
1466    /// # Errors
1467    /// Returns [`NodeError::UnknownDb`] when absent.
1468    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}