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