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, VecDeque};
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
10
11use corium_core::{Datom, IndexOrder, KeywordInterner, Schema};
12use corium_db::{Db, Idents};
13use corium_log::{LogError, TransactionLog, TxRecord};
14use corium_protocol::codec::{self, CodecError};
15use corium_protocol::pb;
16use corium_protocol::schemaform::{SchemaFormError, schema_from_edn};
17use corium_protocol::txforms::{TxFormError, tx_items_from_edn};
18use corium_query::edn::Edn;
19use corium_store::{
20    BlobId, BlobStore, RootStore, StoreError, decode_index_manifest, decode_segment_keys,
21    is_index_manifest, mark_and_sweep_retained, meta_root_name,
22};
23use thiserror::Error;
24use tokio::sync::{broadcast, oneshot, watch};
25use tracing::Instrument;
26
27use crate::backend::{LogBackend, NodeStore, StoreSpec};
28use crate::lease::{self, Lease, LeaseError};
29use crate::metrics::Metrics;
30use crate::{DbRoot, EmbeddedTransactor, Prepared, TransactError, db_root_name};
31
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    /// Most transactions grouped into one commit batch (group commit). A batch
88    /// commits under one durable append and one ownership fence, so a larger
89    /// cap raises peak write throughput under high concurrency at the cost of a
90    /// larger log object per batch; `1` effectively disables batching. Ignored
91    /// once [`Self::max_commit_batch_bytes`] is reached first.
92    pub max_commit_batch: usize,
93    /// Byte budget for one commit batch: it stops accepting more transactions
94    /// once their combined encoded size reaches this, bounding the per-batch
95    /// log object even when transactions are large. At least one transaction
96    /// always commits, so a single oversized transaction is not blocked.
97    pub max_commit_batch_bytes: usize,
98    /// Optional database-function expander (`:db/fn` support).
99    pub tx_fn_expander: Option<Arc<dyn TxFnExpander>>,
100}
101
102impl std::fmt::Debug for NodeConfig {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("NodeConfig")
105            .field("store", &self.store)
106            .field("data_dir", &self.data_dir)
107            .field("owner", &self.owner)
108            .field("lease_ttl_ms", &self.lease_ttl_ms)
109            .field("lease_wait_ms", &self.lease_wait_ms)
110            .field("ha", &self.ha)
111            .field("advertise", &self.advertise)
112            .field("index_interval", &self.index_interval)
113            .field("index_backoff", &self.index_backoff)
114            .field("index_tail_threshold", &self.index_tail_threshold)
115            .field("index_tail_deadline", &self.index_tail_deadline)
116            .field("heartbeat_interval", &self.heartbeat_interval)
117            .field("gc_interval", &self.gc_interval)
118            .field("gc_retention", &self.gc_retention)
119            .field("max_commit_batch", &self.max_commit_batch)
120            .field("max_commit_batch_bytes", &self.max_commit_batch_bytes)
121            .field("tx_fn_expander", &self.tx_fn_expander.is_some())
122            .finish()
123    }
124}
125
126impl NodeConfig {
127    /// Sensible defaults for a data directory.
128    #[must_use]
129    pub fn new(data_dir: PathBuf) -> Self {
130        Self {
131            store: StoreSpec::Fs,
132            data_dir,
133            owner: format!(
134                "transactor-{}",
135                std::env::var("HOSTNAME").unwrap_or_else(|_| "local".into())
136            ),
137            lease_ttl_ms: 5_000,
138            lease_wait_ms: 15_000,
139            ha: false,
140            advertise: None,
141            index_interval: Duration::from_secs(5),
142            index_backoff: 4,
143            index_tail_threshold: 0,
144            index_tail_deadline: Duration::from_secs(60),
145            heartbeat_interval: Duration::from_secs(10),
146            gc_interval: Some(Duration::from_secs(60 * 60)),
147            gc_retention: Duration::from_secs(72 * 60 * 60),
148            max_commit_batch: 256,
149            max_commit_batch_bytes: 4 * 1024 * 1024,
150            #[cfg(feature = "cljrs")]
151            tx_fn_expander: Some(Arc::new(crate::txfn::DbFnExpander::default())),
152            #[cfg(not(feature = "cljrs"))]
153            tx_fn_expander: None,
154        }
155    }
156}
157
158/// Pacing policy for one database's background indexing job.
159///
160/// A publication is due when the adaptive floor has elapsed — the base
161/// interval stretched by a multiple of the previous publication's duration,
162/// which bounds the indexing duty cycle as full republication gets slower —
163/// and the pending log tail is either large enough to be worth rewriting
164/// every index or old enough that deferring it further would leave cold
165/// readers and backups too far behind.
166///
167/// Every database starts from the node's [`NodeConfig`] pacing fields; the
168/// catalog `SetIndexPolicy` RPC (or
169/// [`TransactorNode::set_index_policy`]) overrides it at runtime.
170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
171pub struct IndexPolicy {
172    /// Base interval between publications ([`NodeConfig::index_interval`]).
173    pub interval: Duration,
174    /// Duty-cycle multiplier on the previous publication's duration
175    /// ([`NodeConfig::index_backoff`]).
176    pub backoff: u32,
177    /// Pending-datom count below which a due publication is deferred
178    /// ([`NodeConfig::index_tail_threshold`]).
179    pub tail_threshold: u64,
180    /// Longest a below-threshold tail may defer publication
181    /// ([`NodeConfig::index_tail_deadline`]).
182    pub tail_deadline: Duration,
183}
184
185/// Partial [`IndexPolicy`] override; `None` fields are left unchanged.
186#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
187pub struct IndexPolicyUpdate {
188    /// New base interval, when set.
189    pub interval: Option<Duration>,
190    /// New duty-cycle multiplier, when set.
191    pub backoff: Option<u32>,
192    /// New pending-datom threshold, when set.
193    pub tail_threshold: Option<u64>,
194    /// New deferral deadline, when set.
195    pub tail_deadline: Option<Duration>,
196}
197
198impl IndexPolicy {
199    fn from_config(config: &NodeConfig) -> Self {
200        Self {
201            interval: config.index_interval,
202            backoff: config.index_backoff,
203            tail_threshold: config.index_tail_threshold,
204            tail_deadline: config.index_tail_deadline,
205        }
206    }
207
208    fn apply(&mut self, update: IndexPolicyUpdate) {
209        if let Some(interval) = update.interval {
210            self.interval = interval;
211        }
212        if let Some(backoff) = update.backoff {
213            self.backoff = backoff;
214        }
215        if let Some(tail_threshold) = update.tail_threshold {
216            self.tail_threshold = tail_threshold;
217        }
218        if let Some(tail_deadline) = update.tail_deadline {
219            self.tail_deadline = tail_deadline;
220        }
221    }
222
223    /// Decides whether pending work should publish now. `since_publish` is
224    /// the time since the last publication finished (or the job started),
225    /// `last_duration` how long it took (zero before the first), and
226    /// `pending` the recorded datoms appended since it — `None` until a
227    /// publication in this process establishes a baseline, which publishes
228    /// at base pacing (covers restarting with an unindexed backlog).
229    fn due(&self, since_publish: Duration, last_duration: Duration, pending: Option<u64>) -> bool {
230        let floor = self
231            .interval
232            .max(last_duration.saturating_mul(self.backoff));
233        if since_publish < floor {
234            return false;
235        }
236        match pending {
237            Some(pending) if pending < self.tail_threshold => since_publish >= self.tail_deadline,
238            _ => true,
239        }
240    }
241}
242
243/// Node operation failure.
244#[derive(Debug, Error)]
245pub enum NodeError {
246    /// Named database does not exist.
247    #[error("unknown database {0:?}")]
248    UnknownDb(String),
249    /// Database name is not storable.
250    #[error("invalid database name {0:?}")]
251    InvalidName(String),
252    /// Database root uses a storage format newer than this binary.
253    #[error("storage format {found} is newer than supported format {supported}")]
254    UnsupportedFormat {
255        /// Version found in the root.
256        found: u32,
257        /// Newest version understood by this binary.
258        supported: u32,
259    },
260    /// This node no longer holds the write lease.
261    #[error("deposed: write lease for {0:?} is held elsewhere")]
262    Deposed(String),
263    /// This node is a warm standby for the database; the lease holder
264    /// serves it.
265    #[error("standby for {db:?}: lease held by {owner} at {endpoint:?}")]
266    Standby {
267        /// Database name.
268        db: String,
269        /// Current lease owner id (empty when unknown).
270        owner: String,
271        /// Owner's advertised client endpoint (empty when unadvertised).
272        endpoint: String,
273    },
274    /// Payload failed to decode.
275    #[error(transparent)]
276    Codec(#[from] CodecError),
277    /// Transaction forms failed to convert.
278    #[error(transparent)]
279    TxForm(#[from] TxFormError),
280    /// Schema forms failed to convert.
281    #[error(transparent)]
282    SchemaForm(#[from] SchemaFormError),
283    /// Transaction pipeline failure.
284    #[error(transparent)]
285    Transact(#[from] TransactError),
286    /// Store failure.
287    #[error(transparent)]
288    Store(#[from] StoreError),
289    /// Log failure.
290    #[error(transparent)]
291    Log(#[from] LogError),
292    /// Lease failure.
293    #[error(transparent)]
294    Lease(#[from] LeaseError),
295    /// Malformed request.
296    #[error("bad request: {0}")]
297    BadRequest(String),
298    /// A conditional transaction was prepared from a stale database value.
299    #[error("basis changed: expected {expected}, current basis is {actual}")]
300    BasisMismatch {
301        /// Basis supplied by the caller.
302        expected: u64,
303        /// Basis current when the request reached the commit queue.
304        actual: u64,
305    },
306    /// A group-commit batch aborted after preparation (durable append,
307    /// ownership fence, or metadata publish failed); every batched caller
308    /// receives this so it retries. Carries the originating error's text
309    /// because the underlying store/log errors are not cloneable.
310    #[error("group commit aborted: {0}")]
311    GroupCommit(String),
312}
313
314struct Naming {
315    schema: Schema,
316    idents: Idents,
317    interner: KeywordInterner,
318}
319
320/// One caller's queued transaction, awaiting a group-commit flush. The
321/// leader that flushes the queue answers `resp`.
322struct CommitRequest {
323    forms: Vec<Edn>,
324    expected_basis_t: Option<u64>,
325    resp: oneshot::Sender<Result<pb::TransactResponse, NodeError>>,
326}
327
328/// Per-database state hosted by a node.
329pub struct DbState {
330    name: String,
331    transactor: EmbeddedTransactor,
332    log: Arc<dyn TransactionLog>,
333    naming: Mutex<Naming>,
334    /// Held by the batch leader while it flushes the pending queue; also taken
335    /// by lease renewal so a renewal never interleaves with a commit's
336    /// ownership checks.
337    commit: tokio::sync::Mutex<()>,
338    /// Transactions queued for the next group-commit flush.
339    pending: Mutex<VecDeque<CommitRequest>>,
340    broadcast: broadcast::Sender<pb::subscribe_item::Item>,
341    basis: watch::Sender<u64>,
342    index_basis: AtomicU64,
343    index_policy: Mutex<IndexPolicy>,
344    held_lease: Mutex<Lease>,
345    deposed: AtomicBool,
346}
347
348impl DbState {
349    /// Database name.
350    #[must_use]
351    pub fn name(&self) -> &str {
352        &self.name
353    }
354
355    /// Current database value.
356    #[must_use]
357    pub fn db(&self) -> Db {
358        self.transactor.db()
359    }
360
361    /// Watch channel following the commit basis.
362    #[must_use]
363    pub fn basis_watch(&self) -> watch::Receiver<u64> {
364        self.basis.subscribe()
365    }
366
367    /// Subscribes to live stream items (reports, index announcements,
368    /// heartbeats).
369    #[must_use]
370    pub fn stream_items(&self) -> broadcast::Receiver<pb::subscribe_item::Item> {
371        self.broadcast.subscribe()
372    }
373
374    /// Basis of the newest published index root.
375    #[must_use]
376    pub fn index_basis(&self) -> u64 {
377        self.index_basis.load(Ordering::Acquire)
378    }
379
380    /// The indexing pacing policy currently in effect for this database.
381    #[must_use]
382    pub fn index_policy(&self) -> IndexPolicy {
383        *self
384            .index_policy
385            .lock()
386            .unwrap_or_else(std::sync::PoisonError::into_inner)
387    }
388
389    /// Currently held lease record.
390    #[must_use]
391    pub fn lease(&self) -> Lease {
392        self.held_lease
393            .lock()
394            .unwrap_or_else(std::sync::PoisonError::into_inner)
395            .clone()
396    }
397
398    /// Encoded schema/ident handshake payload plus a consistent basis and
399    /// interner snapshot for backfill encoding.
400    #[must_use]
401    pub fn handshake_snapshot(&self) -> (Vec<u8>, KeywordInterner) {
402        let naming = self
403            .naming
404            .lock()
405            .unwrap_or_else(std::sync::PoisonError::into_inner);
406        (
407            codec::encode_schema(&naming.schema, &naming.idents),
408            naming.interner.clone(),
409        )
410    }
411
412    /// Reads committed records in `[start, end)` from the durable log.
413    ///
414    /// # Errors
415    /// Returns an error when the log cannot be read.
416    pub async fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, NodeError> {
417        Ok(self.log.tx_range_async(start, end).await?)
418    }
419
420    /// Verifies this node still owns the write lease (identity check on
421    /// the root record; expiry changes from renewals do not matter).
422    async fn check_lease(&self, store: &dyn RootStore) -> Result<Lease, NodeError> {
423        if self.deposed.load(Ordering::Acquire) {
424            return Err(NodeError::Deposed(self.name.clone()));
425        }
426        let held = self.lease();
427        match lease::verify(store, &self.name, &held).await {
428            Ok(()) => Ok(held),
429            Err(LeaseError::Lost) => {
430                self.deposed.store(true, Ordering::Release);
431                Err(NodeError::Deposed(self.name.clone()))
432            }
433            Err(error) => Err(error.into()),
434        }
435    }
436}
437
438/// A running transactor node hosting every database under one data directory.
439pub struct TransactorNode {
440    config: NodeConfig,
441    store: Arc<NodeStore>,
442    log_backend: LogBackend,
443    dbs: std::sync::RwLock<HashMap<String, Arc<DbState>>>,
444    /// Databases this node is standing by for (HA mode): the lease is held
445    /// elsewhere and the standby poller attempts takeover on expiry.
446    standby: std::sync::RwLock<BTreeSet<String>>,
447    gc_lock: tokio::sync::Mutex<()>,
448    /// Serializes forks: two forks to the same target must not interleave
449    /// appends into one target log.
450    fork_lock: tokio::sync::Mutex<()>,
451    metrics: Metrics,
452    shutdown: watch::Sender<Option<String>>,
453}
454
455fn now_unix_ms() -> i64 {
456    i64::try_from(
457        SystemTime::now()
458            .duration_since(UNIX_EPOCH)
459            .unwrap_or_default()
460            .as_millis(),
461    )
462    .unwrap_or(i64::MAX)
463}
464
465/// The error a group-commit batch hands every one of its callers when it
466/// aborts after preparation. `Deposed` is preserved structurally so callers
467/// fail over; other store/log errors (not cloneable) surface as
468/// [`NodeError::GroupCommit`], which carries the text and maps to the same
469/// retriable status the single-transaction path returned.
470fn batch_abort_error(name: &str, error: &NodeError) -> NodeError {
471    match error {
472        NodeError::Deposed(_) => NodeError::Deposed(name.to_owned()),
473        other => NodeError::GroupCommit(other.to_string()),
474    }
475}
476
477fn valid_db_name(name: &str) -> bool {
478    !name.is_empty()
479        && name.len() <= 128
480        && name
481            .bytes()
482            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
483}
484
485impl TransactorNode {
486    /// Opens a node over `config.data_dir`, recovering every database found
487    /// there (acquiring its lease, waiting out held leases up to the
488    /// configured bound).
489    ///
490    /// # Errors
491    /// Returns an error when the store cannot be opened or a database cannot
492    /// be recovered.
493    pub async fn open(config: NodeConfig) -> Result<Arc<Self>, NodeError> {
494        let store = Arc::new(NodeStore::open(&config.store, &config.data_dir).await?);
495        let log_backend = LogBackend::for_spec(&config.store, &config.data_dir, Arc::clone(&store));
496        let node = Arc::new(Self {
497            config,
498            store,
499            log_backend,
500            dbs: std::sync::RwLock::new(HashMap::new()),
501            standby: std::sync::RwLock::new(BTreeSet::new()),
502            gc_lock: tokio::sync::Mutex::new(()),
503            fork_lock: tokio::sync::Mutex::new(()),
504            metrics: Metrics::default(),
505            shutdown: watch::channel(None).0,
506        });
507        let names: Vec<String> = node
508            .store
509            .list_roots("meta:")
510            .await?
511            .into_iter()
512            .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
513            .collect();
514        for name in names {
515            match node.open_db(&name).await {
516                Ok(state) => {
517                    node.dbs
518                        .write()
519                        .unwrap_or_else(std::sync::PoisonError::into_inner)
520                        .insert(name, state);
521                }
522                Err(NodeError::Lease(LeaseError::Held { owner, .. })) if node.config.ha => {
523                    tracing::info!(db = %name, %owner, "standing by; lease held elsewhere");
524                    node.standby
525                        .write()
526                        .unwrap_or_else(std::sync::PoisonError::into_inner)
527                        .insert(name);
528                }
529                Err(error) => return Err(error),
530            }
531        }
532        node.spawn_standby_poller();
533        node.spawn_scheduled_gc();
534        Ok(node)
535    }
536
537    /// The node's storage-service backend (blobs + roots).
538    #[must_use]
539    pub fn store(&self) -> &Arc<NodeStore> {
540        &self.store
541    }
542
543    /// Node configuration.
544    #[must_use]
545    pub fn config(&self) -> &NodeConfig {
546        &self.config
547    }
548
549    /// Process observability counters.
550    #[must_use]
551    pub const fn metrics(&self) -> &Metrics {
552        &self.metrics
553    }
554
555    fn spawn_scheduled_gc(self: &Arc<Self>) {
556        let Some(interval) = self.config.gc_interval else {
557            return;
558        };
559        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
560            // Embedded callers may construct an empty catalog before they
561            // enter a runtime. Process wiring opens nodes inside Tokio.
562            return;
563        };
564        let node = Arc::clone(self);
565        runtime.spawn(async move {
566            let mut ticker = tokio::time::interval(interval);
567            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
568            // `interval` ticks immediately; scheduled duties should wait a full interval.
569            ticker.tick().await;
570            loop {
571                ticker.tick().await;
572                if let Err(error) = node.gc_deleted().await {
573                    tracing::warn!(%error, "scheduled garbage collection failed");
574                }
575            }
576        });
577    }
578
579    /// Watch channel that reports a shutdown reason when the node deposes.
580    #[must_use]
581    pub fn shutdown_watch(&self) -> watch::Receiver<Option<String>> {
582        self.shutdown.subscribe()
583    }
584
585    /// Deposes a hosted database. In HA mode the database returns to
586    /// standby (the poller re-attempts takeover); otherwise the whole
587    /// process shuts down and a supervisor restart re-acquires or waits.
588    fn depose(&self, state: &DbState, reason: &str) {
589        state.deposed.store(true, Ordering::Release);
590        if self.config.ha {
591            tracing::warn!(db = %state.name, reason, "deposed; returning to standby");
592            self.dbs
593                .write()
594                .unwrap_or_else(std::sync::PoisonError::into_inner)
595                .remove(&state.name);
596            self.standby
597                .write()
598                .unwrap_or_else(std::sync::PoisonError::into_inner)
599                .insert(state.name.clone());
600        } else {
601            let _ = self
602                .shutdown
603                .send(Some(format!("database {:?}: {reason}", state.name)));
604        }
605    }
606
607    fn advertised(&self) -> &str {
608        self.config.advertise.as_deref().unwrap_or("")
609    }
610
611    /// Acquires the lease for `name`. In HA mode a held lease surfaces
612    /// immediately (the caller stands by); otherwise startup waits it out
613    /// up to the configured bound.
614    async fn acquire_lease(&self, name: &str) -> Result<Lease, NodeError> {
615        let deadline = now_unix_ms() + self.config.lease_wait_ms;
616        loop {
617            match lease::acquire(
618                self.store.as_ref(),
619                name,
620                &self.config.owner,
621                self.advertised(),
622                self.config.lease_ttl_ms,
623                now_unix_ms(),
624            )
625            .await
626            {
627                Ok(held) => return Ok(held),
628                Err(LeaseError::Held { .. }) if !self.config.ha && now_unix_ms() < deadline => {
629                    tokio::time::sleep(Duration::from_millis(200)).await;
630                }
631                Err(error) => return Err(error.into()),
632            }
633        }
634    }
635
636    async fn open_db(self: &Arc<Self>, name: &str) -> Result<Arc<DbState>, NodeError> {
637        let meta = self
638            .store
639            .get_root(&meta_root_name(name))
640            .await?
641            .ok_or_else(|| NodeError::UnknownDb(name.to_owned()))?;
642        let (schema, idents, interner) = codec::decode_metadata(&meta)?;
643        let root_name = db_root_name(name);
644        let current = self
645            .store
646            .get_root(&root_name)
647            .await?
648            .as_deref()
649            .and_then(DbRoot::decode);
650        if let Some(root) = &current
651            && root.format_version > corium_store::FORMAT_VERSION
652        {
653            return Err(NodeError::UnsupportedFormat {
654                found: root.format_version,
655                supported: corium_store::FORMAT_VERSION,
656            });
657        }
658        // Acquisition rewrites the root record under our lease version, so
659        // it doubles as the fence bump: a deposed writer's pending root CAS
660        // now has stale expected bytes and must fail. It also preserves the
661        // published snapshot's recovery hints, so the root we re-read below
662        // carries everything index-root recovery needs.
663        let held = self.acquire_lease(name).await?;
664        // The log tail replay below happens strictly after the fence, so it
665        // observes every record a previous owner could ever have acked.
666        let log = self.log_backend.open(name, held.version).await?;
667        let post_fence = self
668            .store
669            .get_root(&root_name)
670            .await?
671            .as_deref()
672            .and_then(DbRoot::decode);
673        let transactor = self
674            .recover_transactor(name, &schema, &idents, &interner, post_fence.as_ref(), &log)
675            .await?;
676        let basis_t = transactor.db().basis_t();
677        let index_basis = post_fence.map_or(0, |root| root.index_basis_t);
678        let state = Arc::new(DbState {
679            name: name.to_owned(),
680            transactor,
681            log,
682            naming: Mutex::new(Naming {
683                schema,
684                idents,
685                interner,
686            }),
687            commit: tokio::sync::Mutex::new(()),
688            pending: Mutex::new(VecDeque::new()),
689            broadcast: broadcast::channel(1024).0,
690            basis: watch::channel(basis_t).0,
691            index_basis: AtomicU64::new(index_basis),
692            index_policy: Mutex::new(IndexPolicy::from_config(&self.config)),
693            held_lease: Mutex::new(held),
694            deposed: AtomicBool::new(false),
695        });
696        self.spawn_maintenance(&state);
697        Ok(state)
698    }
699
700    /// Builds the recovered transactor for `open_db`.
701    ///
702    /// When the post-fence root publishes a current snapshot with recovery
703    /// hints, recovers from the index root plus the log tail — open time
704    /// proportional to the tail, not the whole history. Any missing hint
705    /// (a pre-recovery root, or a bare fence bump with no snapshot) or a
706    /// failure materializing the snapshot falls back to full-log replay,
707    /// which is always correct because the log is the source of truth.
708    async fn recover_transactor(
709        &self,
710        name: &str,
711        schema: &Schema,
712        idents: &Idents,
713        interner: &KeywordInterner,
714        root: Option<&DbRoot>,
715        log: &Arc<dyn TransactionLog>,
716    ) -> Result<EmbeddedTransactor, NodeError> {
717        // `next_entity_id == 0` is the "no hint" sentinel (see DbRoot); it and
718        // an absent snapshot both rule out the tail-only path.
719        if let Some(root) = root
720            && let Some(roots) = &root.roots
721            && root.next_entity_id != 0
722        {
723            match self
724                .load_current_snapshot(
725                    root,
726                    &roots[IndexOrder::Eavt as usize],
727                    schema,
728                    idents,
729                    interner,
730                )
731                .await
732            {
733                Ok(snapshot) => {
734                    return Ok(EmbeddedTransactor::recover_from_snapshot_async(
735                        snapshot,
736                        root.next_entity_id,
737                        root.last_tx_instant,
738                        Arc::clone(log),
739                    )
740                    .await?);
741                }
742                Err(error) => {
743                    tracing::warn!(
744                        db = %name,
745                        %error,
746                        "index-root recovery failed; falling back to full-log replay"
747                    );
748                }
749            }
750        }
751        let base = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
752        Ok(EmbeddedTransactor::recover_from_async(base, Arc::clone(log)).await?)
753    }
754
755    /// Materializes the current database value at a published index root from
756    /// its EAVT snapshot — the transactor-side counterpart of the peer's
757    /// bootstrap (`corium-peer`'s `load_current_snapshot`). Only current
758    /// facts are reconstructed; the log tail carries everything since.
759    async fn load_current_snapshot(
760        &self,
761        root: &DbRoot,
762        eavt: &BlobId,
763        schema: &Schema,
764        idents: &Idents,
765        interner: &KeywordInterner,
766    ) -> Result<Db, StoreError> {
767        let datoms = self
768            .load_index_keys(eavt)
769            .await?
770            .into_iter()
771            .map(|key| Datom::from_key(IndexOrder::Eavt, &key))
772            .collect::<Result<Vec<_>, _>>()
773            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
774        Ok(Db::from_current_snapshot(
775            root.index_basis_t,
776            schema.clone(),
777            idents.clone(),
778            interner.clone(),
779            datoms,
780        ))
781    }
782
783    /// Reads one covering index's sorted key stream from the blob store: a
784    /// format-3 manifest's chunks in order, or a pre-format-3 flat blob.
785    async fn load_index_keys(&self, id: &BlobId) -> Result<Vec<Vec<u8>>, StoreError> {
786        let blob = self
787            .store
788            .get(id)
789            .await?
790            .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
791        if !is_index_manifest(&blob) {
792            return decode_segment_keys(&blob);
793        }
794        let mut keys = Vec::new();
795        for child in decode_index_manifest(&blob)? {
796            let chunk = self
797                .store
798                .get(&child)
799                .await?
800                .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
801            keys.extend(decode_segment_keys(&chunk)?);
802        }
803        Ok(keys)
804    }
805
806    /// HA standby duty: at the lease-renewal cadence, rediscover databases
807    /// (including ones created on the active after this process started)
808    /// and attempt takeover of any whose lease has lapsed. Takeover is
809    /// ordinary startup — acquire (which fences), replay the log tail,
810    /// serve — per the crash-only design.
811    fn spawn_standby_poller(self: &Arc<Self>) {
812        if !self.config.ha {
813            return;
814        }
815        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
816            return;
817        };
818        let ttl = self.config.lease_ttl_ms;
819        let poll_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
820        let node = Arc::clone(self);
821        runtime.spawn(async move {
822            let mut ticker = tokio::time::interval(poll_every);
823            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
824            loop {
825                ticker.tick().await;
826                if let Err(error) = node.standby_scan().await {
827                    tracing::warn!(%error, "standby scan failed");
828                }
829            }
830        });
831    }
832
833    /// One standby pass: refresh the standby set from the catalog and try
834    /// to take over lapsed leases.
835    async fn standby_scan(self: &Arc<Self>) -> Result<(), NodeError> {
836        let names: Vec<String> = self
837            .store
838            .list_roots("meta:")
839            .await?
840            .into_iter()
841            .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
842            .collect();
843        {
844            let mut standby = self
845                .standby
846                .write()
847                .unwrap_or_else(std::sync::PoisonError::into_inner);
848            standby.retain(|name| names.contains(name));
849        }
850        for name in names {
851            if self
852                .dbs
853                .read()
854                .unwrap_or_else(std::sync::PoisonError::into_inner)
855                .contains_key(&name)
856            {
857                continue;
858            }
859            match self.open_db(&name).await {
860                Ok(state) => {
861                    tracing::info!(db = %name, owner = %self.config.owner, "standby took over write lease");
862                    self.standby
863                        .write()
864                        .unwrap_or_else(std::sync::PoisonError::into_inner)
865                        .remove(&name);
866                    self.dbs
867                        .write()
868                        .unwrap_or_else(std::sync::PoisonError::into_inner)
869                        .insert(name, state);
870                }
871                Err(NodeError::Lease(LeaseError::Held { .. })) => {
872                    self.standby
873                        .write()
874                        .unwrap_or_else(std::sync::PoisonError::into_inner)
875                        .insert(name);
876                }
877                Err(error) => {
878                    tracing::warn!(db = %name, %error, "standby takeover attempt failed");
879                }
880            }
881        }
882        Ok(())
883    }
884
885    fn spawn_maintenance(self: &Arc<Self>, state: &Arc<DbState>) {
886        let ttl = self.config.lease_ttl_ms;
887        let renew_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
888        // Lease renewal.
889        let node = Arc::clone(self);
890        let db = Arc::clone(state);
891        tokio::spawn(async move {
892            let mut ticker = tokio::time::interval(renew_every);
893            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
894            loop {
895                ticker.tick().await;
896                if db.deposed.load(Ordering::Acquire) {
897                    return;
898                }
899                // Serialize the root update and local held-lease update with
900                // transaction lease checks so they cannot observe different
901                // renewal generations and falsely depose this node.
902                let _commit = db.commit.lock().await;
903                let held = db.lease();
904                let name = db.name.clone();
905                let renewed =
906                    lease::renew(node.store.as_ref(), &name, &held, ttl, now_unix_ms()).await;
907                match renewed {
908                    Ok(renewed) => {
909                        *db.held_lease
910                            .lock()
911                            .unwrap_or_else(std::sync::PoisonError::into_inner) = renewed;
912                    }
913                    Err(LeaseError::Lost) => {
914                        node.depose(&db, "write lease lost");
915                        return;
916                    }
917                    Err(_) => {}
918                }
919            }
920        });
921        self.spawn_indexing(state);
922        // Heartbeats.
923        let node = Arc::clone(self);
924        let db = Arc::clone(state);
925        tokio::spawn(async move {
926            let mut ticker = tokio::time::interval(node.config.heartbeat_interval);
927            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
928            loop {
929                ticker.tick().await;
930                if db.deposed.load(Ordering::Acquire) {
931                    return;
932                }
933                let _ = db
934                    .broadcast
935                    .send(pb::subscribe_item::Item::Heartbeat(pb::Heartbeat {
936                        basis_t: db.db().basis_t(),
937                    }));
938            }
939        });
940    }
941
942    /// Spawns the background indexing job, paced by the database's
943    /// [`IndexPolicy`] (re-read every iteration so runtime overrides apply
944    /// within a poll interval).
945    fn spawn_indexing(self: &Arc<Self>, state: &Arc<DbState>) {
946        // How often the job re-checks work and policy when the configured
947        // interval is longer; runtime policy changes and pending work are
948        // never noticed later than this.
949        const POLICY_POLL: Duration = Duration::from_secs(1);
950        let node = Arc::clone(self);
951        let db = Arc::clone(state);
952        tokio::spawn(async move {
953            let mut published_at = Instant::now();
954            let mut last_duration = Duration::ZERO;
955            let mut published_len: Option<u64> = None;
956            loop {
957                let policy = db.index_policy();
958                tokio::time::sleep(policy.interval.min(POLICY_POLL)).await;
959                if db.deposed.load(Ordering::Acquire) {
960                    return;
961                }
962                let snapshot = db.db();
963                if snapshot.basis_t() <= db.index_basis() {
964                    continue;
965                }
966                let recorded_len = u64::try_from(snapshot.recorded_len()).unwrap_or(u64::MAX);
967                let pending = published_len.map(|len| recorded_len.saturating_sub(len));
968                if !policy.due(published_at.elapsed(), last_duration, pending) {
969                    continue;
970                }
971                match node.publish_db_indexes(&db).await {
972                    Ok((_, duration)) => {
973                        last_duration = duration;
974                        // publish_db_indexes snapshots after this loop did,
975                        // so the covered length is at least recorded_len; the
976                        // underestimate only makes the next tail look bigger.
977                        published_len = Some(recorded_len);
978                    }
979                    Err(NodeError::Deposed(_)) => return,
980                    Err(_) => {}
981                }
982                published_at = Instant::now();
983            }
984        });
985    }
986
987    /// Publishes `db`'s covering indexes now, returning the published index
988    /// basis and how long the publication took. Serialized with garbage
989    /// collection; deposes the database when the root is fenced by a newer
990    /// lease.
991    async fn publish_db_indexes(&self, db: &Arc<DbState>) -> Result<(u64, Duration), NodeError> {
992        let _gc = self.gc_lock.lock().await;
993        let version = db.lease().version;
994        let root_name = db_root_name(&db.name);
995        let started = Instant::now();
996        let published = db
997            .transactor
998            .publish_indexes(self.store.as_ref(), &root_name, version)
999            .await;
1000        let duration = started.elapsed();
1001        self.metrics.record_index(duration);
1002        match published {
1003            Ok(root) => {
1004                tracing::debug!(db = %db.name, index_basis_t = root.index_basis_t, "published indexes");
1005                db.index_basis.store(root.index_basis_t, Ordering::Release);
1006                let _ = db
1007                    .broadcast
1008                    .send(pb::subscribe_item::Item::IndexBasis(pb::IndexBasis {
1009                        index_basis_t: root.index_basis_t,
1010                    }));
1011                Ok((root.index_basis_t, duration))
1012            }
1013            Err(TransactError::Deposed { .. }) => {
1014                self.depose(db, "database root fenced by a newer lease");
1015                Err(NodeError::Deposed(db.name.clone()))
1016            }
1017            Err(error) => Err(error.into()),
1018        }
1019    }
1020
1021    /// Publishes indexes for `name` immediately, bypassing the pacing
1022    /// policy (the catalog `RequestIndex` RPC). Returns the resulting index
1023    /// basis; when the published indexes already cover every committed
1024    /// transaction, returns the current index basis without publishing.
1025    ///
1026    /// # Errors
1027    /// Returns [`NodeError`] when the database is unknown, this node is
1028    /// deposed or standing by, or publication fails.
1029    pub async fn request_index(&self, name: &str) -> Result<u64, NodeError> {
1030        let state = self.db_state(name).await?;
1031        if state.db().basis_t() <= state.index_basis() {
1032            return Ok(state.index_basis());
1033        }
1034        self.publish_db_indexes(&state)
1035            .await
1036            .map(|(index_basis_t, _)| index_basis_t)
1037    }
1038
1039    /// Applies per-database indexing-policy overrides at runtime, returning
1040    /// the policy now in effect. An empty update reads the current policy.
1041    ///
1042    /// # Errors
1043    /// Returns [`NodeError`] when the database is unknown or served
1044    /// elsewhere.
1045    pub async fn set_index_policy(
1046        &self,
1047        name: &str,
1048        update: IndexPolicyUpdate,
1049    ) -> Result<IndexPolicy, NodeError> {
1050        let state = self.db_state(name).await?;
1051        let mut policy = state
1052            .index_policy
1053            .lock()
1054            .unwrap_or_else(std::sync::PoisonError::into_inner);
1055        policy.apply(update);
1056        Ok(*policy)
1057    }
1058
1059    /// Looks up a hosted database.
1060    ///
1061    /// # Errors
1062    /// Returns [`NodeError::Standby`] when this HA node is standing by for
1063    /// the database, [`NodeError::UnknownDb`] when absent.
1064    pub async fn db_state(&self, name: &str) -> Result<Arc<DbState>, NodeError> {
1065        if let Some(state) = self
1066            .dbs
1067            .read()
1068            .unwrap_or_else(std::sync::PoisonError::into_inner)
1069            .get(name)
1070            .cloned()
1071        {
1072            return Ok(state);
1073        }
1074        if self.config.ha
1075            && self
1076                .standby
1077                .read()
1078                .unwrap_or_else(std::sync::PoisonError::into_inner)
1079                .contains(name)
1080        {
1081            let root = self
1082                .store
1083                .get_root(&db_root_name(name))
1084                .await?
1085                .as_deref()
1086                .and_then(DbRoot::decode);
1087            return Err(NodeError::Standby {
1088                db: name.to_owned(),
1089                owner: root.as_ref().map(|r| r.owner.clone()).unwrap_or_default(),
1090                endpoint: root.map(|r| r.owner_endpoint).unwrap_or_default(),
1091            });
1092        }
1093        Err(NodeError::UnknownDb(name.to_owned()))
1094    }
1095
1096    /// Databases this node currently stands by for (HA mode).
1097    #[must_use]
1098    pub fn standby_dbs(&self) -> Vec<String> {
1099        self.standby
1100            .read()
1101            .unwrap_or_else(std::sync::PoisonError::into_inner)
1102            .iter()
1103            .cloned()
1104            .collect()
1105    }
1106
1107    /// Creates a database with the supplied EDN schema forms; returns
1108    /// `false` when it already exists.
1109    ///
1110    /// # Errors
1111    /// Returns an error for invalid names/schema or store failures.
1112    pub async fn create_db(
1113        self: &Arc<Self>,
1114        name: &str,
1115        schema_edn: &[u8],
1116    ) -> Result<bool, NodeError> {
1117        if !valid_db_name(name) {
1118            return Err(NodeError::InvalidName(name.to_owned()));
1119        }
1120        if self
1121            .dbs
1122            .read()
1123            .unwrap_or_else(std::sync::PoisonError::into_inner)
1124            .contains_key(name)
1125        {
1126            return Ok(false);
1127        }
1128        let forms = match codec::decode_edn(schema_edn)? {
1129            Edn::Vector(items) | Edn::List(items) => items,
1130            Edn::Nil => Vec::new(),
1131            other => {
1132                return Err(NodeError::BadRequest(format!(
1133                    "schema must be a vector of attribute maps, got {other}"
1134                )));
1135            }
1136        };
1137        let (schema, idents) = schema_from_edn(&forms)?;
1138        let meta = codec::encode_metadata(&schema, &idents, &KeywordInterner::default());
1139        match self
1140            .store
1141            .cas_root(&meta_root_name(name), None, &meta)
1142            .await
1143        {
1144            Ok(()) => {}
1145            Err(StoreError::CasFailed { .. }) => return Ok(false),
1146            Err(error) => return Err(error.into()),
1147        }
1148        let state = self.open_db(name).await?;
1149        self.dbs
1150            .write()
1151            .unwrap_or_else(std::sync::PoisonError::into_inner)
1152            .insert(name.to_owned(), state);
1153        Ok(true)
1154    }
1155
1156    /// Forks `source` into a new database `target` whose state duplicates
1157    /// the source as of transaction `as_of_t` (`0` forks at the current
1158    /// basis). Only the log prefix is copied; the target replays it and
1159    /// publishes its own indexes, while blob segments dedupe by content
1160    /// address. Returns the fork's basis, or `None` when `target` already
1161    /// exists.
1162    ///
1163    /// # Errors
1164    /// Returns an error for an invalid target name, an unknown source, an
1165    /// `as_of_t` ahead of the source's basis, or store/log failures.
1166    pub async fn fork_db(
1167        self: &Arc<Self>,
1168        source: &str,
1169        target: &str,
1170        as_of_t: u64,
1171    ) -> Result<Option<u64>, NodeError> {
1172        if !valid_db_name(target) {
1173            return Err(NodeError::InvalidName(target.to_owned()));
1174        }
1175        if source == target {
1176            return Err(NodeError::BadRequest(
1177                "fork target must differ from the source".into(),
1178            ));
1179        }
1180        let state = self.db_state(source).await?;
1181        let basis = state.db().basis_t();
1182        let t = if as_of_t == 0 { basis } else { as_of_t };
1183        if t > basis {
1184            return Err(NodeError::BadRequest(format!(
1185                "as-of t {t} is ahead of {source:?} basis {basis}"
1186            )));
1187        }
1188        let _guard = self.fork_lock.lock().await;
1189        if self
1190            .dbs
1191            .read()
1192            .unwrap_or_else(std::sync::PoisonError::into_inner)
1193            .contains_key(target)
1194            || self
1195                .store
1196                .get_root(&meta_root_name(target))
1197                .await?
1198                .is_some()
1199            || self.log_backend.exists(target).await
1200        {
1201            return Ok(None);
1202        }
1203        // Capture the records before the metadata: meta is made durable
1204        // before any record that references it, so a meta read afterwards is
1205        // always a sufficient decode dictionary for the captured prefix.
1206        // Transaction numbers are contiguous from 1, so the prefix through
1207        // `t` is exactly the source's state at that basis.
1208        let records = state.log.tx_range_async(0, Some(t + 1)).await?;
1209        let meta = self
1210            .store
1211            .get_root(&meta_root_name(source))
1212            .await?
1213            .ok_or_else(|| NodeError::UnknownDb(source.to_owned()))?;
1214        // Write the log under version 0 so it sorts beneath the
1215        // lease-versioned file the target's first open creates, and publish
1216        // meta last — it is the catalog entry, so a crash mid-fork never
1217        // catalogs a target without its log.
1218        let log = self.log_backend.open(target, 0).await?;
1219        for record in &records {
1220            log.append_async(record).await?;
1221        }
1222        drop(log);
1223        match self
1224            .store
1225            .cas_root(&meta_root_name(target), None, &meta)
1226            .await
1227        {
1228            Ok(()) => {}
1229            Err(StoreError::CasFailed { .. }) => {
1230                // Another node claimed the name first; discard our log copy.
1231                self.log_backend.delete_all(target).await?;
1232                return Ok(None);
1233            }
1234            Err(error) => return Err(error.into()),
1235        }
1236        let state = self.open_db(target).await?;
1237        self.dbs
1238            .write()
1239            .unwrap_or_else(std::sync::PoisonError::into_inner)
1240            .insert(target.to_owned(), state);
1241        Ok(Some(t))
1242    }
1243
1244    /// Deletes a database: unhosts it, releases its lease, and removes its
1245    /// roots and log. Blobs remain until [`Self::gc_deleted`].
1246    ///
1247    /// # Errors
1248    /// Returns an error when roots or the log cannot be removed.
1249    pub async fn delete_db(&self, name: &str) -> Result<bool, NodeError> {
1250        let Some(state) = self
1251            .dbs
1252            .write()
1253            .unwrap_or_else(std::sync::PoisonError::into_inner)
1254            .remove(name)
1255        else {
1256            return Ok(false);
1257        };
1258        state.deposed.store(true, Ordering::Release);
1259        self.standby
1260            .write()
1261            .unwrap_or_else(std::sync::PoisonError::into_inner)
1262            .remove(name);
1263        self.store.delete_root(&db_root_name(name)).await?;
1264        self.store.delete_root(&meta_root_name(name)).await?;
1265        self.log_backend.delete_all(name).await?;
1266        Ok(true)
1267    }
1268
1269    /// Lists hosted databases.
1270    #[must_use]
1271    pub fn list_dbs(&self) -> Vec<String> {
1272        let mut names: Vec<String> = self
1273            .dbs
1274            .read()
1275            .unwrap_or_else(std::sync::PoisonError::into_inner)
1276            .keys()
1277            .cloned()
1278            .collect();
1279        names.sort();
1280        names
1281    }
1282
1283    /// Sweeps blobs unreachable from any live database root (including
1284    /// everything left behind by deleted databases and superseded indexes).
1285    ///
1286    /// # Errors
1287    /// Returns an error when the store cannot be enumerated or swept.
1288    pub async fn gc_deleted(&self) -> Result<u64, NodeError> {
1289        self.gc_deleted_with_retention(self.config.gc_retention)
1290            .await
1291    }
1292
1293    /// Sweeps unreachable blobs older than the caller-supplied retention.
1294    ///
1295    /// # Errors
1296    /// Returns an error when the store cannot be enumerated or swept.
1297    pub async fn gc_deleted_with_retention(&self, retention: Duration) -> Result<u64, NodeError> {
1298        let _gc = self.gc_lock.lock().await;
1299        let mut live = Vec::new();
1300        for root_name in self.store.list_roots("db:").await? {
1301            if let Some(root) = self
1302                .store
1303                .get_root(&root_name)
1304                .await?
1305                .as_deref()
1306                .and_then(DbRoot::decode)
1307                && let Some(roots) = root.roots
1308            {
1309                live.extend(roots);
1310            }
1311        }
1312        let report = mark_and_sweep_retained(
1313            self.store.as_ref(),
1314            live,
1315            |_, bytes| corium_store::index_blob_children(bytes),
1316            retention,
1317            SystemTime::now(),
1318        )
1319        .await?;
1320        self.metrics
1321            .record_gc(report.swept as u64, report.retained as u64);
1322        tracing::info!(
1323            marked = report.marked,
1324            swept = report.swept,
1325            retained = report.retained,
1326            "garbage collection completed"
1327        );
1328        Ok(report.swept as u64)
1329    }
1330
1331    /// Validates, appends, applies, and reports one transaction supplied as
1332    /// composite-encoded EDN transaction forms.
1333    ///
1334    /// # Errors
1335    /// Returns [`NodeError`] for decode/validation failures, lease loss, or
1336    /// storage failures.
1337    pub async fn transact(
1338        &self,
1339        name: &str,
1340        tx_data: &[u8],
1341    ) -> Result<pb::TransactResponse, NodeError> {
1342        self.transact_at(name, tx_data, None).await
1343    }
1344
1345    /// Applies one transaction only if `expected_basis_t` is still current.
1346    ///
1347    /// An absent expectation preserves the ordinary Corium transaction
1348    /// behavior. Conditional callers receive [`NodeError::BasisMismatch`]
1349    /// before preparation or durability when another transaction won first.
1350    ///
1351    /// # Errors
1352    /// Returns [`NodeError`] for a stale basis, decode/validation failures,
1353    /// lease loss, or storage failures.
1354    pub async fn transact_at(
1355        &self,
1356        name: &str,
1357        tx_data: &[u8],
1358        expected_basis_t: Option<u64>,
1359    ) -> Result<pb::TransactResponse, NodeError> {
1360        let started = Instant::now();
1361        let result = self
1362            .transact_inner(name, tx_data, expected_basis_t)
1363            .instrument(tracing::info_span!("transact", db = name))
1364            .await;
1365        self.metrics.record_tx(started.elapsed(), result.is_ok());
1366        if let Err(error) = &result {
1367            tracing::warn!(%error, "transaction failed");
1368        }
1369        result
1370    }
1371
1372    async fn transact_inner(
1373        &self,
1374        name: &str,
1375        tx_data: &[u8],
1376        expected_basis_t: Option<u64>,
1377    ) -> Result<pb::TransactResponse, NodeError> {
1378        let state = self.db_state(name).await?;
1379        let decoded = codec::decode_edn(tx_data)?;
1380        let forms = decoded
1381            .as_seq()
1382            .ok_or_else(|| NodeError::BadRequest("tx-data must be a vector".into()))?
1383            .to_vec();
1384        // Enqueue for the next group-commit flush, then contend to lead one.
1385        // Whichever caller holds `commit` drains the queue and commits the
1386        // whole run under one durable append and one ownership fence, then
1387        // answers every queued caller — so batching is invisible to clients:
1388        // each transaction keeps its own `t`, report, and ack.
1389        let (resp_tx, mut resp_rx) = oneshot::channel();
1390        state
1391            .pending
1392            .lock()
1393            .unwrap_or_else(std::sync::PoisonError::into_inner)
1394            .push_back(CommitRequest {
1395                forms,
1396                expected_basis_t,
1397                resp: resp_tx,
1398            });
1399        let queued = self.metrics.queue_waiter();
1400        loop {
1401            let commit = state.commit.lock().await;
1402            // A prior leader may already have committed this request.
1403            match resp_rx.try_recv() {
1404                Ok(result) => {
1405                    drop(commit);
1406                    drop(queued);
1407                    return result;
1408                }
1409                Err(oneshot::error::TryRecvError::Empty) => {}
1410                Err(oneshot::error::TryRecvError::Closed) => {
1411                    drop(commit);
1412                    drop(queued);
1413                    return Err(NodeError::GroupCommit("commit response dropped".into()));
1414                }
1415            }
1416            // Lead a flush of the pending queue (which contains this request).
1417            self.flush_commit_batch(&state).await;
1418            drop(commit);
1419            match resp_rx.try_recv() {
1420                Ok(result) => {
1421                    drop(queued);
1422                    return result;
1423                }
1424                // A naming change ends a batch before this request; the
1425                // remainder was requeued, so loop and lead the next flush.
1426                Err(oneshot::error::TryRecvError::Empty) => {}
1427                Err(oneshot::error::TryRecvError::Closed) => {
1428                    drop(queued);
1429                    return Err(NodeError::GroupCommit("commit response dropped".into()));
1430                }
1431            }
1432        }
1433    }
1434
1435    /// Group-commit flush, run by the batch leader while it holds
1436    /// `state.commit`: drains the pending queue, prepares the run against a
1437    /// staging value (so each transaction still validates against its
1438    /// predecessors), makes the whole run durable with one batched append and
1439    /// one post-append ownership fence, then installs it and answers every
1440    /// caller. A transaction that interns new keywords ends the batch — so a
1441    /// later transaction never depends on names not yet durable — and the
1442    /// unprepared remainder is requeued for the next flush.
1443    #[allow(clippy::too_many_lines)]
1444    async fn flush_commit_batch(&self, state: &Arc<DbState>) {
1445        let max_count = self.config.max_commit_batch.max(1);
1446        let max_bytes = self.config.max_commit_batch_bytes;
1447
1448        let mut batch: VecDeque<CommitRequest> = {
1449            let mut pending = state
1450                .pending
1451                .lock()
1452                .unwrap_or_else(std::sync::PoisonError::into_inner);
1453            std::mem::take(&mut *pending)
1454        };
1455        if batch.is_empty() {
1456            return;
1457        }
1458        // No pre-append ownership check on the common path: the post-append
1459        // fence below is the safety-critical one, and skipping the pre-check
1460        // removes a lease round trip per batch. A deposed leader still prepares
1461        // and appends (harmlessly, under its old lease version, which the
1462        // successor's cutoff discards), then the fence refuses to acknowledge.
1463        // The one exception is a batch that interns new keywords, which
1464        // publishes the unfenced metadata root — that path re-checks ownership
1465        // before writing it, below.
1466        let now_ms = now_unix_ms();
1467        let mut cursor = state.transactor.batch_cursor();
1468        let mut resps: Vec<oneshot::Sender<Result<pb::TransactResponse, NodeError>>> = Vec::new();
1469        let mut prepared: Vec<Prepared> = Vec::new();
1470        let mut batch_bytes: usize = 0;
1471        let mut measure = Vec::new();
1472        let mut naming_changed = false;
1473        while let Some(request) = batch.pop_front() {
1474            if let Some(expected) = request.expected_basis_t {
1475                let actual = cursor.db().basis_t();
1476                if actual != expected {
1477                    let _ = request
1478                        .resp
1479                        .send(Err(NodeError::BasisMismatch { expected, actual }));
1480                    continue;
1481                }
1482            }
1483            // Expand `:db/fn` against the staging value, so each transaction
1484            // sees the earlier ones in the batch. The expander blocks up to
1485            // its budget, so it runs off the async workers.
1486            let forms = if let Some(expander) = &self.config.tx_fn_expander {
1487                let expander = Arc::clone(expander);
1488                let db = cursor.db().clone();
1489                let forms = request.forms;
1490                match tokio::task::spawn_blocking(move || expander.expand(&db, forms)).await {
1491                    Ok(Ok(forms)) => forms,
1492                    Ok(Err(message)) => {
1493                        let _ = request.resp.send(Err(NodeError::BadRequest(message)));
1494                        continue;
1495                    }
1496                    Err(error) => {
1497                        let _ = request.resp.send(Err(NodeError::BadRequest(format!(
1498                            "expander task failed: {error}"
1499                        ))));
1500                        continue;
1501                    }
1502                }
1503            } else {
1504                request.forms
1505            };
1506            // Convert forms, interning new keyword values into the shared
1507            // naming, against the staging value.
1508            let (items, this_changed) = {
1509                let mut naming = state
1510                    .naming
1511                    .lock()
1512                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1513                let before = naming.interner.len();
1514                match tx_items_from_edn(cursor.db(), &mut naming.interner, &forms) {
1515                    Ok(items) => (items, naming.interner.len() > before),
1516                    Err(error) => {
1517                        drop(naming);
1518                        let _ = request.resp.send(Err(error.into()));
1519                        continue;
1520                    }
1521                }
1522            };
1523            match cursor.prepare(items, now_ms) {
1524                Ok(prep) => {
1525                    measure.clear();
1526                    let _ = corium_log::append_framed_record(&mut measure, &prep.record);
1527                    batch_bytes += measure.len();
1528                    resps.push(request.resp);
1529                    prepared.push(prep);
1530                }
1531                Err(error) => {
1532                    let _ = request.resp.send(Err(NodeError::Transact(error.into())));
1533                    continue;
1534                }
1535            }
1536            if this_changed {
1537                naming_changed = true;
1538                break;
1539            }
1540            // Cap the batch by transaction count or accumulated encoded size.
1541            // The transaction that crosses the byte budget is already included,
1542            // so at least one — even a single oversized transaction — commits.
1543            if prepared.len() >= max_count || batch_bytes >= max_bytes {
1544                break;
1545            }
1546        }
1547        // Requeue the unprepared remainder at the front of the queue, in order.
1548        if !batch.is_empty() {
1549            let mut pending = state
1550                .pending
1551                .lock()
1552                .unwrap_or_else(std::sync::PoisonError::into_inner);
1553            while let Some(request) = batch.pop_back() {
1554                pending.push_front(request);
1555            }
1556        }
1557        if prepared.is_empty() {
1558            return;
1559        }
1560        // Snapshot the interner for response encoding; capture the idents only
1561        // when naming changed, to carry into `update_naming` after install.
1562        let interner = {
1563            let naming = state
1564                .naming
1565                .lock()
1566                .unwrap_or_else(std::sync::PoisonError::into_inner);
1567            naming.interner.clone()
1568        };
1569        let changed_idents = if naming_changed {
1570            // Publishing new keyword names writes the metadata root, which is
1571            // not lease-fenced, so verify ownership before writing it. This is
1572            // the one lease check the common (no-new-keyword) path skips.
1573            if let Err(error) = state.check_lease(self.store.as_ref()).await {
1574                if matches!(error, NodeError::Deposed(_)) {
1575                    self.depose(state, "write lease lost before metadata publish");
1576                }
1577                for resp in resps {
1578                    let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1579                }
1580                return;
1581            }
1582            let (idents, schema) = {
1583                let naming = state
1584                    .naming
1585                    .lock()
1586                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1587                (naming.idents.clone(), naming.schema.clone())
1588            };
1589            // New keyword names must be durable before the datoms that
1590            // reference them; recovery decodes the log against this meta.
1591            let meta = codec::encode_metadata(&schema, &idents, &interner);
1592            loop {
1593                let cas = match self.store.get_root(&meta_root_name(&state.name)).await {
1594                    Ok(current) => {
1595                        self.store
1596                            .cas_root(&meta_root_name(&state.name), current.as_deref(), &meta)
1597                            .await
1598                    }
1599                    Err(error) => Err(error),
1600                };
1601                match cas {
1602                    Ok(()) => break,
1603                    Err(StoreError::CasFailed { .. }) => {}
1604                    Err(error) => {
1605                        let error = NodeError::Store(error);
1606                        for resp in resps {
1607                            let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1608                        }
1609                        return;
1610                    }
1611                }
1612            }
1613            Some(idents)
1614        } else {
1615            None
1616        };
1617        // One durable append for the whole batch — the commit point.
1618        let records: Vec<TxRecord> = prepared.iter().map(|prep| prep.record.clone()).collect();
1619        if let Err(error) = state.log.append_batch_async(&records).await {
1620            let error = NodeError::Log(error);
1621            for resp in resps {
1622                let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1623            }
1624            return;
1625        }
1626        // Install in memory now — while still holding `commit`, before the
1627        // fence — so the live value stays in lock-step with the durable log
1628        // regardless of the fence outcome (exactly as the single-transaction
1629        // path applied before its fence). Installing advances the value and
1630        // notifies in-process subscribers; it does not acknowledge callers.
1631        let reports = state.transactor.install_batch(cursor, prepared);
1632        if let Some(idents) = changed_idents {
1633            state.transactor.update_naming(idents, interner.clone());
1634        }
1635        // Post-append fence gates only the acknowledgement and the peer
1636        // stream: ack a batch only if ownership was intact after it became
1637        // durable. A takeover that raced the append replayed the log *after*
1638        // rewriting the root record, so a batch we ack is provably in the
1639        // successor's replay; one we refuse is discarded by the successor's
1640        // cutoff — and because the whole batch is one atomic object, the cutoff
1641        // keeps all or none of it. One fence covers the batch (see
1642        // log-and-transactor.md).
1643        if let Err(error) = state.check_lease(self.store.as_ref()).await {
1644            if matches!(error, NodeError::Deposed(_)) {
1645                self.depose(state, "write lease lost after durable append");
1646            }
1647            for resp in resps {
1648                let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
1649            }
1650            return;
1651        }
1652        let mut last_t = 0;
1653        for (resp, report) in resps.into_iter().zip(reports) {
1654            let t = report.db_after.basis_t();
1655            last_t = last_t.max(t);
1656            let datoms = match codec::encode_datoms(&report.tx.datoms, &interner) {
1657                Ok(datoms) => datoms,
1658                Err(error) => {
1659                    let _ = resp.send(Err(NodeError::Codec(error)));
1660                    continue;
1661                }
1662            };
1663            let tempids = codec::encode_edn(&Edn::Map(
1664                report
1665                    .tx
1666                    .tempids
1667                    .iter()
1668                    .map(|(tempid, eid)| {
1669                        (
1670                            Edn::Str(tempid.clone()),
1671                            Edn::Long(i64::try_from(eid.raw()).unwrap_or(i64::MAX)),
1672                        )
1673                    })
1674                    .collect(),
1675            ));
1676            let _ = state
1677                .broadcast
1678                .send(pb::subscribe_item::Item::Report(pb::TxReport {
1679                    t,
1680                    tx_instant: report.tx_instant,
1681                    datoms: datoms.clone(),
1682                }));
1683            let _ = resp.send(Ok(pb::TransactResponse {
1684                basis_before: report.db_before.basis_t(),
1685                basis_t: t,
1686                tx_instant: report.tx_instant,
1687                tempids,
1688                tx_data: datoms,
1689            }));
1690        }
1691        if last_t > 0 {
1692            let _ = state.basis.send(last_t);
1693        }
1694    }
1695
1696    /// Current status for a database.
1697    ///
1698    /// # Errors
1699    /// Returns [`NodeError::UnknownDb`] when absent.
1700    pub async fn status(&self, name: &str) -> Result<pb::StatusResponse, NodeError> {
1701        let state = self.db_state(name).await?;
1702        let db = state.db();
1703        let counts = db.stats();
1704        let held = state.lease();
1705        let metrics = self.metrics.snapshot();
1706        Ok(pb::StatusResponse {
1707            basis_t: db.basis_t(),
1708            index_basis_t: state.index_basis(),
1709            lease_owner: held.owner,
1710            lease_version: held.version,
1711            lease_expires_unix_ms: held.expires_unix_ms,
1712            datom_count: counts.datoms as u64,
1713            entity_count: counts.entities as u64,
1714            attribute_count: counts.attributes as u64,
1715            transaction_count: metrics.tx_total,
1716            transaction_failure_count: metrics.tx_failed,
1717            transaction_queue_depth: metrics.queue_depth,
1718            index_lag: db.basis_t().saturating_sub(state.index_basis()),
1719            indexing_runs: metrics.index_runs,
1720            gc_runs: metrics.gc_runs,
1721            gc_swept_blobs: metrics.gc_swept,
1722            lease_owner_endpoint: held.endpoint,
1723        })
1724    }
1725
1726    /// Fixes the current transaction basis and returns the connection details
1727    /// an administrative client needs to replay the underlying storage log
1728    /// independently.
1729    ///
1730    /// # Errors
1731    /// Returns [`NodeError::UnknownDb`] when absent, or a bad-request error
1732    /// when local connection details cannot be represented on the wire.
1733    pub async fn backup_info(&self, name: &str) -> Result<pb::GetStorageInfoResponse, NodeError> {
1734        let state = self.db_state(name).await?;
1735        // Serialize with the tiny commit critical section so the checkpoint
1736        // cannot observe a batch after its durable append but before its
1737        // ownership fence and acknowledgement decision.
1738        let _commit = state.commit.lock().await;
1739        state.check_lease(self.store.as_ref()).await?;
1740        let basis_t = state.db().basis_t();
1741        let storage = self
1742            .config
1743            .store
1744            .connection_info(&self.config.data_dir)
1745            .map_err(NodeError::BadRequest)?;
1746        Ok(pb::GetStorageInfoResponse {
1747            basis_t,
1748            storage: Some(storage),
1749        })
1750    }
1751
1752    /// Releases every held write lease (graceful shutdown): the record is
1753    /// expired in place so a standby's next poll takes over immediately
1754    /// instead of waiting out the TTL. Hosted databases stop accepting
1755    /// work first, so nothing commits after its lease is gone.
1756    pub async fn release_leases(&self) {
1757        let states: Vec<Arc<DbState>> = self
1758            .dbs
1759            .write()
1760            .unwrap_or_else(std::sync::PoisonError::into_inner)
1761            .drain()
1762            .map(|(_, state)| state)
1763            .collect();
1764        for state in states {
1765            state.deposed.store(true, Ordering::Release);
1766            if let Err(error) =
1767                lease::release(self.store.as_ref(), &state.name, &state.lease()).await
1768            {
1769                tracing::warn!(db = %state.name, %error, "lease release failed at shutdown");
1770            }
1771        }
1772    }
1773
1774    /// Waits until the database basis reaches `t`, returning the basis seen.
1775    ///
1776    /// # Errors
1777    /// Returns [`NodeError::UnknownDb`] when absent.
1778    pub async fn sync(&self, name: &str, t: u64) -> Result<u64, NodeError> {
1779        let state = self.db_state(name).await?;
1780        let mut basis = state.basis_watch();
1781        let target = if t == 0 { *basis.borrow() } else { t };
1782        loop {
1783            let current = *basis.borrow();
1784            if current >= target {
1785                return Ok(current);
1786            }
1787            if basis.changed().await.is_err() {
1788                return Ok(*basis.borrow());
1789            }
1790        }
1791    }
1792}
1793
1794#[cfg(test)]
1795mod tests {
1796    use super::IndexPolicy;
1797    use std::time::Duration;
1798
1799    fn pacing(interval_ms: u64, backoff: u32, threshold: u64, deadline_ms: u64) -> IndexPolicy {
1800        IndexPolicy {
1801            interval: Duration::from_millis(interval_ms),
1802            backoff,
1803            tail_threshold: threshold,
1804            tail_deadline: Duration::from_millis(deadline_ms),
1805        }
1806    }
1807
1808    #[test]
1809    fn base_interval_gates_publication() {
1810        let pacing = pacing(100, 4, 0, 60_000);
1811        assert!(!pacing.due(Duration::from_millis(99), Duration::ZERO, None));
1812        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1813    }
1814
1815    #[test]
1816    fn backoff_stretches_the_floor_past_the_interval() {
1817        let pacing = pacing(100, 4, 0, 60_000);
1818        let last = Duration::from_millis(300);
1819        assert!(!pacing.due(Duration::from_millis(1_199), last, Some(10)));
1820        assert!(pacing.due(Duration::from_millis(1_200), last, Some(10)));
1821    }
1822
1823    #[test]
1824    fn zero_backoff_keeps_the_base_interval() {
1825        let pacing = pacing(100, 0, 0, 60_000);
1826        assert!(pacing.due(
1827            Duration::from_millis(100),
1828            Duration::from_secs(30),
1829            Some(10)
1830        ));
1831    }
1832
1833    #[test]
1834    fn fast_publications_leave_the_interval_untouched() {
1835        let pacing = pacing(5_000, 4, 0, 60_000);
1836        assert!(pacing.due(Duration::from_secs(5), Duration::from_millis(3), Some(1)));
1837    }
1838
1839    #[test]
1840    fn small_tail_defers_until_the_deadline() {
1841        let pacing = pacing(100, 4, 1_000, 60_000);
1842        assert!(!pacing.due(Duration::from_secs(30), Duration::ZERO, Some(999)));
1843        assert!(pacing.due(Duration::from_secs(60), Duration::ZERO, Some(999)));
1844    }
1845
1846    #[test]
1847    fn tail_at_threshold_publishes_at_base_pacing() {
1848        let pacing = pacing(100, 4, 1_000, 60_000);
1849        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, Some(1_000)));
1850    }
1851
1852    #[test]
1853    fn unknown_tail_publishes_at_base_pacing() {
1854        let pacing = pacing(100, 4, 1_000, 60_000);
1855        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1856    }
1857
1858    #[test]
1859    fn deadline_never_overrides_the_backoff_floor() {
1860        let pacing = pacing(100, 4, 1_000, 200);
1861        let last = Duration::from_millis(300);
1862        assert!(!pacing.due(Duration::from_millis(400), last, Some(1)));
1863        assert!(pacing.due(Duration::from_millis(1_200), last, Some(1)));
1864    }
1865}