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