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, AtomicU32, 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_crypt::{KeyId, Keyring};
13use corium_db::{Db, Idents};
14use corium_log::{LogError, TransactionLog, TxRecord};
15use corium_protocol::codec::{self, CodecError};
16use corium_protocol::pb;
17use corium_protocol::schemaform::{SchemaFormError, schema_from_edn};
18use corium_protocol::txforms::{TxFormError, tx_items_from_edn};
19use corium_query::edn::Edn;
20use corium_store::{
21    BlobId, BlobStore, KeyManifest, RootStore, StoreError, decode_index_manifest,
22    decode_segment_keys, is_index_manifest, keys_root_name, load_key_manifest, mark_reachable,
23    meta_root_name, publish_key_manifest, sweep_unmarked,
24};
25use thiserror::Error;
26use tokio::sync::{broadcast, oneshot, watch};
27use tracing::Instrument;
28
29use crate::backend::{LogBackend, NodeStore, StorageInfoConfig, StoreSpec};
30use crate::keys::{DbCrypto, DbStore, KeyWiringError, reload_db_crypto, resolve_db_crypto};
31use crate::lease::{self, Lease, LeaseError};
32use crate::metrics::Metrics;
33use crate::{DbRoot, EmbeddedTransactor, Prepared, TransactError, db_root_name};
34
35/// Expands user database-function invocations in boundary EDN transaction
36/// forms before native conversion. The built-in implementation is
37/// [`crate::txfn::DbFnExpander`] on the bounded `cljrs-tx` runtime (feature
38/// `cljrs`, on by default, ADR-0008); embedders may inject their own.
39pub trait TxFnExpander: Send + Sync {
40    /// Rewrites `forms` with every `[:my/fn arg…]` invocation replaced by
41    /// the function's returned tx-data (recursively).
42    ///
43    /// # Errors
44    /// Returns a display message when a function is missing, rejected by
45    /// the sandbox, fails, or exceeds its budget; the transaction aborts.
46    fn expand(&self, db: &Db, forms: Vec<Edn>) -> Result<Vec<Edn>, String>;
47}
48
49/// Node process configuration.
50#[derive(Clone)]
51pub struct NodeConfig {
52    /// Storage-service backend for blobs and roots (`mem`, `fs`, or Turso).
53    pub store: StoreSpec,
54    /// Separately provisioned read-only service credentials advertised by
55    /// `GetStorageInfo`. Local backends do not use this setting.
56    pub storage_info: StorageInfoConfig,
57    /// Data directory holding the filesystem blob/root store (for the `fs`
58    /// backend) and the transaction logs (for every non-`mem` backend).
59    pub data_dir: PathBuf,
60    /// Stable owner identity for lease records.
61    pub owner: String,
62    /// Lease time-to-live in milliseconds.
63    pub lease_ttl_ms: i64,
64    /// How long to wait for a held lease to expire before giving up.
65    pub lease_wait_ms: i64,
66    /// High-availability mode: when another owner holds a database's lease,
67    /// stand by and take over on expiry instead of failing startup, and on
68    /// depose return to standby instead of shutting the process down.
69    pub ha: bool,
70    /// Client endpoint advertised in the lease for peer lease-holder
71    /// rediscovery (e.g. `http://transactor-a:4334`).
72    pub advertise: Option<String>,
73    /// Interval between background index publications.
74    pub index_interval: Duration,
75    /// Minimum wait before the next index publication, as a multiple of the
76    /// previous publication's duration. Publications currently rewrite every
77    /// index in full, so this stretches the effective interval as the
78    /// database grows, bounding the share of time and storage bandwidth
79    /// spent republishing to at most `1/(1+n)`; 0 disables the backoff.
80    pub index_backoff: u32,
81    /// Pending log-tail growth (recorded datoms) below which a due
82    /// publication is deferred, so trickle writes coalesce instead of
83    /// rewriting every index; 0 publishes any pending work.
84    pub index_tail_threshold: u64,
85    /// Longest a pending below-threshold tail may defer publication.
86    pub index_tail_deadline: Duration,
87    /// Interval between heartbeats on subscription streams.
88    pub heartbeat_interval: Duration,
89    /// Interval between scheduled garbage-collection duties; `None` disables it.
90    pub gc_interval: Option<Duration>,
91    /// Minimum age of an unreachable blob before scheduled/manual online GC.
92    pub gc_retention: Duration,
93    /// Most transactions grouped into one commit batch (group commit). A batch
94    /// commits under one durable append and one ownership fence, so a larger
95    /// cap raises peak write throughput under high concurrency at the cost of a
96    /// larger log object per batch; `1` effectively disables batching. Ignored
97    /// once [`Self::max_commit_batch_bytes`] is reached first.
98    pub max_commit_batch: usize,
99    /// Byte budget for one commit batch: it stops accepting more transactions
100    /// once their combined encoded size reaches this, bounding the per-batch
101    /// log object even when transactions are large. At least one transaction
102    /// always commits, so a single oversized transaction is not blocked.
103    pub max_commit_batch_bytes: usize,
104    /// Optional database-function expander (`:db/fn` support).
105    pub tx_fn_expander: Option<Arc<dyn TxFnExpander>>,
106    /// Keys this process can resolve, for databases encrypted at rest.
107    ///
108    /// A node without one serves unencrypted databases exactly as before and
109    /// refuses to open an encrypted one, naming the key its manifest wants.
110    pub keyring: Option<Arc<dyn Keyring>>,
111}
112
113impl std::fmt::Debug for NodeConfig {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("NodeConfig")
116            .field("store", &self.store)
117            .field("storage_info", &self.storage_info)
118            .field("data_dir", &self.data_dir)
119            .field("owner", &self.owner)
120            .field("lease_ttl_ms", &self.lease_ttl_ms)
121            .field("lease_wait_ms", &self.lease_wait_ms)
122            .field("ha", &self.ha)
123            .field("advertise", &self.advertise)
124            .field("index_interval", &self.index_interval)
125            .field("index_backoff", &self.index_backoff)
126            .field("index_tail_threshold", &self.index_tail_threshold)
127            .field("index_tail_deadline", &self.index_tail_deadline)
128            .field("heartbeat_interval", &self.heartbeat_interval)
129            .field("gc_interval", &self.gc_interval)
130            .field("gc_retention", &self.gc_retention)
131            .field("max_commit_batch", &self.max_commit_batch)
132            .field("max_commit_batch_bytes", &self.max_commit_batch_bytes)
133            .field("tx_fn_expander", &self.tx_fn_expander.is_some())
134            .field("keyring", &self.keyring.is_some())
135            .finish()
136    }
137}
138
139impl NodeConfig {
140    /// Sensible defaults for a data directory.
141    #[must_use]
142    pub fn new(data_dir: PathBuf) -> Self {
143        Self {
144            store: StoreSpec::Fs,
145            storage_info: StorageInfoConfig::default(),
146            data_dir,
147            owner: format!(
148                "transactor-{}",
149                std::env::var("HOSTNAME").unwrap_or_else(|_| "local".into())
150            ),
151            lease_ttl_ms: 5_000,
152            lease_wait_ms: 15_000,
153            ha: false,
154            advertise: None,
155            index_interval: Duration::from_secs(5),
156            index_backoff: 4,
157            index_tail_threshold: 0,
158            index_tail_deadline: Duration::from_secs(60),
159            heartbeat_interval: Duration::from_secs(10),
160            gc_interval: Some(Duration::from_secs(60 * 60)),
161            gc_retention: Duration::from_secs(72 * 60 * 60),
162            max_commit_batch: 256,
163            max_commit_batch_bytes: 4 * 1024 * 1024,
164            #[cfg(feature = "cljrs")]
165            tx_fn_expander: Some(Arc::new(crate::txfn::DbFnExpander::default())),
166            #[cfg(not(feature = "cljrs"))]
167            tx_fn_expander: None,
168            keyring: None,
169        }
170    }
171}
172
173/// Pacing policy for one database's background indexing job.
174///
175/// A publication is due when the adaptive floor has elapsed — the base
176/// interval stretched by a multiple of the previous publication's duration,
177/// which bounds the indexing duty cycle as full republication gets slower —
178/// and the pending log tail is either large enough to be worth rewriting
179/// every index or old enough that deferring it further would leave cold
180/// readers and backups too far behind.
181///
182/// Every database starts from the node's [`NodeConfig`] pacing fields; the
183/// catalog `SetIndexPolicy` RPC (or
184/// [`TransactorNode::set_index_policy`]) overrides it at runtime.
185#[derive(Clone, Copy, Debug, Eq, PartialEq)]
186pub struct IndexPolicy {
187    /// Base interval between publications ([`NodeConfig::index_interval`]).
188    pub interval: Duration,
189    /// Duty-cycle multiplier on the previous publication's duration
190    /// ([`NodeConfig::index_backoff`]).
191    pub backoff: u32,
192    /// Pending-datom count below which a due publication is deferred
193    /// ([`NodeConfig::index_tail_threshold`]).
194    pub tail_threshold: u64,
195    /// Longest a below-threshold tail may defer publication
196    /// ([`NodeConfig::index_tail_deadline`]).
197    pub tail_deadline: Duration,
198}
199
200/// What `corium keys status` reports for one database.
201///
202/// The two alarm flags are about *this node*, not the stored manifest: they
203/// answer "is the process doing the encrypting actually using the keys the
204/// manifest names?", which the manifest alone cannot say.
205#[derive(Debug)]
206pub struct KeyStatus {
207    /// The stored manifest; `None` for an unencrypted database.
208    pub manifest: Option<KeyManifest>,
209    /// Current transaction basis, which closes the active epoch's nonce span.
210    pub basis_t: u64,
211    /// A manifest change could not be loaded, but the keys in hand still
212    /// serve and still write under the active epoch.
213    pub keys_unavailable: bool,
214    /// This node writes under an epoch the manifest has closed, so writes are
215    /// refused until a reload succeeds.
216    pub keys_fenced: bool,
217}
218
219/// Partial [`IndexPolicy`] override; `None` fields are left unchanged.
220#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
221pub struct IndexPolicyUpdate {
222    /// New base interval, when set.
223    pub interval: Option<Duration>,
224    /// New duty-cycle multiplier, when set.
225    pub backoff: Option<u32>,
226    /// New pending-datom threshold, when set.
227    pub tail_threshold: Option<u64>,
228    /// New deferral deadline, when set.
229    pub tail_deadline: Option<Duration>,
230}
231
232impl IndexPolicy {
233    fn from_config(config: &NodeConfig) -> Self {
234        Self {
235            interval: config.index_interval,
236            backoff: config.index_backoff,
237            tail_threshold: config.index_tail_threshold,
238            tail_deadline: config.index_tail_deadline,
239        }
240    }
241
242    fn apply(&mut self, update: IndexPolicyUpdate) {
243        if let Some(interval) = update.interval {
244            self.interval = interval;
245        }
246        if let Some(backoff) = update.backoff {
247            self.backoff = backoff;
248        }
249        if let Some(tail_threshold) = update.tail_threshold {
250            self.tail_threshold = tail_threshold;
251        }
252        if let Some(tail_deadline) = update.tail_deadline {
253            self.tail_deadline = tail_deadline;
254        }
255    }
256
257    /// Decides whether pending work should publish now. `since_publish` is
258    /// the time since the last publication finished (or the job started),
259    /// `last_duration` how long it took (zero before the first), and
260    /// `pending` the recorded datoms appended since it — `None` until a
261    /// publication in this process establishes a baseline, which publishes
262    /// at base pacing (covers restarting with an unindexed backlog).
263    fn due(&self, since_publish: Duration, last_duration: Duration, pending: Option<u64>) -> bool {
264        let floor = self
265            .interval
266            .max(last_duration.saturating_mul(self.backoff));
267        if since_publish < floor {
268            return false;
269        }
270        match pending {
271            Some(pending) if pending < self.tail_threshold => since_publish >= self.tail_deadline,
272            _ => true,
273        }
274    }
275}
276
277/// Node operation failure.
278#[derive(Debug, Error)]
279pub enum NodeError {
280    /// Named database does not exist.
281    #[error("unknown database {0:?}")]
282    UnknownDb(String),
283    /// Database name is not storable.
284    #[error("invalid database name {0:?}")]
285    InvalidName(String),
286    /// Database root uses a storage format newer than this binary.
287    #[error("storage format {found} is newer than supported format {supported}")]
288    UnsupportedFormat {
289        /// Version found in the root.
290        found: u32,
291        /// Newest version understood by this binary.
292        supported: u32,
293    },
294    /// This node no longer holds the write lease.
295    #[error("deposed: write lease for {0:?} is held elsewhere")]
296    Deposed(String),
297    /// This node is a warm standby for the database; the lease holder
298    /// serves it.
299    #[error("standby for {db:?}: lease held by {owner} at {endpoint:?}")]
300    Standby {
301        /// Database name.
302        db: String,
303        /// Current lease owner id (empty when unknown).
304        owner: String,
305        /// Owner's advertised client endpoint (empty when unadvertised).
306        endpoint: String,
307    },
308    /// Payload failed to decode.
309    #[error(transparent)]
310    Codec(#[from] CodecError),
311    /// Transaction forms failed to convert.
312    #[error(transparent)]
313    TxForm(#[from] TxFormError),
314    /// Schema forms failed to convert.
315    #[error(transparent)]
316    SchemaForm(#[from] SchemaFormError),
317    /// Transaction pipeline failure.
318    #[error(transparent)]
319    Transact(#[from] TransactError),
320    /// Store failure.
321    #[error(transparent)]
322    Store(#[from] StoreError),
323    /// Log failure.
324    #[error(transparent)]
325    Log(#[from] LogError),
326    /// Lease failure.
327    #[error(transparent)]
328    Lease(#[from] LeaseError),
329    /// Storage-encryption keys could not be resolved.
330    #[error(transparent)]
331    Keys(#[from] KeyWiringError),
332    /// The key manifest opened a storage-key epoch this node cannot load, so
333    /// it would otherwise keep sealing records under a closed one.
334    #[error(
335        "database {db:?} writes under storage-key epoch {writing}, but the key manifest          has opened epoch {active} and this node cannot load it;          fix --storage-key so the new epoch resolves"
336    )]
337    KeysFenced {
338        /// Database refusing writes.
339        db: String,
340        /// Epoch the manifest says new writes belong to.
341        active: u32,
342        /// Epoch this node still holds.
343        writing: u32,
344    },
345    /// Malformed request.
346    #[error("bad request: {0}")]
347    BadRequest(String),
348    /// A conditional transaction was prepared from a stale database value.
349    #[error("basis changed: expected {expected}, current basis is {actual}")]
350    BasisMismatch {
351        /// Basis supplied by the caller.
352        expected: u64,
353        /// Basis current when the request reached the commit queue.
354        actual: u64,
355    },
356    /// A group-commit batch aborted after preparation (durable append,
357    /// ownership fence, or metadata publish failed); every batched caller
358    /// receives this so it retries. Carries the originating error's text
359    /// because the underlying store/log errors are not cloneable.
360    #[error("group commit aborted: {0}")]
361    GroupCommit(String),
362}
363
364struct Naming {
365    schema: Schema,
366    idents: Idents,
367    interner: KeywordInterner,
368}
369
370/// One caller's queued transaction, awaiting a group-commit flush. The
371/// leader that flushes the queue answers `resp`.
372struct CommitRequest {
373    forms: Vec<Edn>,
374    expected_basis_t: Option<u64>,
375    resp: oneshot::Sender<Result<pb::TransactResponse, NodeError>>,
376}
377
378/// Per-database state hosted by a node.
379pub struct DbState {
380    name: String,
381    transactor: EmbeddedTransactor,
382    log: Arc<dyn TransactionLog>,
383    /// This database's view of the node's storage service, plus its log
384    /// cipher. A storage-key rotation swaps the blob decorator here and
385    /// installs the new snapshot into the (shared) cipher the open log holds.
386    crypto: std::sync::RwLock<Arc<DbCrypto>>,
387    /// Generation of the key manifest this state was resolved from, mirroring
388    /// `DbRoot::key_manifest_version`. A root carrying a different one means
389    /// another process rotated or re-wrapped, and these keys are stale.
390    key_manifest_version: AtomicU64,
391    /// A manifest change could not be loaded: the keyring cannot resolve the
392    /// KEK it now names, or the KMS holding it is unreachable. The already
393    /// unwrapped snapshot keeps serving, because a re-wrap leaves the data
394    /// keys themselves unchanged and a KMS outage should not take the write
395    /// path down. Observable rather than fatal.
396    keys_unavailable: AtomicBool,
397    /// The manifest's active epoch is *not* the one this node writes under,
398    /// and the keys to adopt it could not be loaded. Unlike the above this is
399    /// unsafe to continue through: every record sealed from here is drawn
400    /// under a key the manifest considers closed, and the log-record nonce
401    /// budget — which is measured as the span of `t` between epochs — stops
402    /// counting them. Writes refuse until a reload succeeds.
403    keys_fenced: AtomicBool,
404    /// The epoch the manifest opened when [`Self::keys_fenced`] was raised, so
405    /// the refusal names both sides of the mismatch.
406    fenced_active_epoch: AtomicU32,
407    naming: Mutex<Naming>,
408    /// Held by the batch leader while it flushes the pending queue; also taken
409    /// by lease renewal so a renewal never interleaves with a commit's
410    /// ownership checks.
411    commit: tokio::sync::Mutex<()>,
412    /// Transactions queued for the next group-commit flush.
413    pending: Mutex<VecDeque<CommitRequest>>,
414    broadcast: broadcast::Sender<pb::subscribe_item::Item>,
415    basis: watch::Sender<u64>,
416    index_basis: AtomicU64,
417    index_policy: Mutex<IndexPolicy>,
418    held_lease: Mutex<Lease>,
419    deposed: AtomicBool,
420}
421
422impl DbState {
423    /// Database name.
424    #[must_use]
425    pub fn name(&self) -> &str {
426        &self.name
427    }
428
429    /// The store this database's blobs are read and written through:
430    /// encrypting for an encrypted database, the bare backend otherwise.
431    #[must_use]
432    pub fn store(&self) -> Arc<DbStore> {
433        Arc::clone(
434            &self
435                .crypto
436                .read()
437                .unwrap_or_else(std::sync::PoisonError::into_inner)
438                .store,
439        )
440    }
441
442    /// Clears both key alarms after a successful reload, reporting whether
443    /// this database had been raising one.
444    fn clear_key_alarms(&self) -> bool {
445        // Both swaps must run, so this is a bitwise `|`, not a short-circuit.
446        self.keys_unavailable.swap(false, Ordering::AcqRel)
447            | self.keys_fenced.swap(false, Ordering::AcqRel)
448    }
449
450    /// Whether either key alarm is currently raised.
451    fn key_alarm_raised(&self) -> bool {
452        self.keys_unavailable.load(Ordering::Acquire) || self.keys_fenced.load(Ordering::Acquire)
453    }
454
455    /// The error writes refuse with while the keys are fenced, if they are.
456    fn keys_fenced_error(&self) -> Option<NodeError> {
457        if !self.keys_fenced.load(Ordering::Acquire) {
458            return None;
459        }
460        let crypto = self.crypto();
461        Some(NodeError::KeysFenced {
462            db: self.name.clone(),
463            active: self.fenced_active_epoch.load(Ordering::Acquire),
464            writing: crypto.store.storage_epoch().unwrap_or_default(),
465        })
466    }
467
468    fn crypto(&self) -> Arc<DbCrypto> {
469        Arc::clone(
470            &self
471                .crypto
472                .read()
473                .unwrap_or_else(std::sync::PoisonError::into_inner),
474        )
475    }
476
477    /// Whether this database's durable artifacts are encrypted.
478    #[must_use]
479    pub fn is_encrypted(&self) -> bool {
480        self.store().storage_epoch().is_some()
481    }
482
483    /// Whether a key-manifest change could not be loaded. The keys in hand
484    /// still serve; see [`Self::keys_fenced`] for the case that does not.
485    #[must_use]
486    pub fn keys_unavailable(&self) -> bool {
487        self.keys_unavailable.load(Ordering::Acquire)
488    }
489
490    /// Whether this node is writing under a storage-key epoch the manifest has
491    /// closed. Writes refuse while this holds.
492    #[must_use]
493    pub fn keys_fenced(&self) -> bool {
494        self.keys_fenced.load(Ordering::Acquire)
495    }
496
497    /// Current database value.
498    #[must_use]
499    pub fn db(&self) -> Db {
500        self.transactor.db()
501    }
502
503    /// Watch channel following the commit basis.
504    #[must_use]
505    pub fn basis_watch(&self) -> watch::Receiver<u64> {
506        self.basis.subscribe()
507    }
508
509    /// Subscribes to live stream items (reports, index announcements,
510    /// heartbeats).
511    #[must_use]
512    pub fn stream_items(&self) -> broadcast::Receiver<pb::subscribe_item::Item> {
513        self.broadcast.subscribe()
514    }
515
516    /// Basis of the newest published index root.
517    #[must_use]
518    pub fn index_basis(&self) -> u64 {
519        self.index_basis.load(Ordering::Acquire)
520    }
521
522    /// The indexing pacing policy currently in effect for this database.
523    #[must_use]
524    pub fn index_policy(&self) -> IndexPolicy {
525        *self
526            .index_policy
527            .lock()
528            .unwrap_or_else(std::sync::PoisonError::into_inner)
529    }
530
531    /// Currently held lease record.
532    #[must_use]
533    pub fn lease(&self) -> Lease {
534        self.held_lease
535            .lock()
536            .unwrap_or_else(std::sync::PoisonError::into_inner)
537            .clone()
538    }
539
540    /// Encoded schema/ident handshake payload plus a consistent basis and
541    /// interner snapshot for backfill encoding.
542    #[must_use]
543    pub fn handshake_snapshot(&self) -> (Vec<u8>, KeywordInterner) {
544        let naming = self
545            .naming
546            .lock()
547            .unwrap_or_else(std::sync::PoisonError::into_inner);
548        (
549            codec::encode_schema(&naming.schema, &naming.idents),
550            naming.interner.clone(),
551        )
552    }
553
554    /// Reads committed records in `[start, end)` from the durable log.
555    ///
556    /// # Errors
557    /// Returns an error when the log cannot be read.
558    pub async fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, NodeError> {
559        Ok(self.log.tx_range_async(start, end).await?)
560    }
561
562    /// Verifies this node still owns the write lease (identity check on
563    /// the root record; expiry changes from renewals do not matter).
564    async fn check_lease(&self, store: &dyn RootStore) -> Result<Lease, NodeError> {
565        if self.deposed.load(Ordering::Acquire) {
566            return Err(NodeError::Deposed(self.name.clone()));
567        }
568        let held = self.lease();
569        match lease::verify(store, &self.name, &held).await {
570            Ok(()) => Ok(held),
571            Err(LeaseError::Lost) => {
572                self.deposed.store(true, Ordering::Release);
573                Err(NodeError::Deposed(self.name.clone()))
574            }
575            Err(error) => Err(error.into()),
576        }
577    }
578}
579
580/// A running transactor node hosting every database under one data directory.
581pub struct TransactorNode {
582    config: NodeConfig,
583    store: Arc<NodeStore>,
584    log_backend: LogBackend,
585    dbs: std::sync::RwLock<HashMap<String, Arc<DbState>>>,
586    /// Databases this node is standing by for (HA mode): the lease is held
587    /// elsewhere and the standby poller attempts takeover on expiry.
588    standby: std::sync::RwLock<BTreeSet<String>>,
589    gc_lock: tokio::sync::Mutex<()>,
590    /// Serializes forks: two forks to the same target must not interleave
591    /// appends into one target log.
592    fork_lock: tokio::sync::Mutex<()>,
593    metrics: Metrics,
594    shutdown: watch::Sender<Option<String>>,
595}
596
597fn now_unix_ms() -> i64 {
598    i64::try_from(
599        SystemTime::now()
600            .duration_since(UNIX_EPOCH)
601            .unwrap_or_default()
602            .as_millis(),
603    )
604    .unwrap_or(i64::MAX)
605}
606
607/// The error a group-commit batch hands every one of its callers when it
608/// aborts after preparation. `Deposed` is preserved structurally so callers
609/// fail over; other store/log errors (not cloneable) surface as
610/// [`NodeError::GroupCommit`], which carries the text and maps to the same
611/// retriable status the single-transaction path returned.
612fn batch_abort_error(name: &str, error: &NodeError) -> NodeError {
613    match error {
614        NodeError::Deposed(_) => NodeError::Deposed(name.to_owned()),
615        other => NodeError::GroupCommit(other.to_string()),
616    }
617}
618
619fn valid_db_name(name: &str) -> bool {
620    !name.is_empty()
621        && name.len() <= 128
622        && name
623            .bytes()
624            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
625}
626
627impl TransactorNode {
628    /// Opens a node over `config.data_dir`, recovering every database found
629    /// there (acquiring its lease, waiting out held leases up to the
630    /// configured bound).
631    ///
632    /// # Errors
633    /// Returns an error when the store cannot be opened or a database cannot
634    /// be recovered.
635    pub async fn open(config: NodeConfig) -> Result<Arc<Self>, NodeError> {
636        #[cfg(feature = "s3")]
637        if matches!(config.store, StoreSpec::S3 { .. }) && config.storage_info.s3.is_none() {
638            tracing::warn!(
639                "S3 read-only credentials are not configured; GetStorageInfo will reject \
640                 peer bootstrap and direct-storage backup requests"
641            );
642        }
643        config.storage_info.initialize().await;
644        let store = Arc::new(NodeStore::open(&config.store, &config.data_dir).await?);
645        let log_backend = LogBackend::for_spec(&config.store, &config.data_dir, Arc::clone(&store));
646        let node = Arc::new(Self {
647            config,
648            store,
649            log_backend,
650            dbs: std::sync::RwLock::new(HashMap::new()),
651            standby: std::sync::RwLock::new(BTreeSet::new()),
652            gc_lock: tokio::sync::Mutex::new(()),
653            fork_lock: tokio::sync::Mutex::new(()),
654            metrics: Metrics::default(),
655            shutdown: watch::channel(None).0,
656        });
657        let names: Vec<String> = node
658            .store
659            .list_roots("meta:")
660            .await?
661            .into_iter()
662            .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
663            .collect();
664        for name in names {
665            match node.open_db(&name).await {
666                Ok(state) => {
667                    node.dbs
668                        .write()
669                        .unwrap_or_else(std::sync::PoisonError::into_inner)
670                        .insert(name, state);
671                }
672                Err(NodeError::Lease(LeaseError::Held { owner, .. })) if node.config.ha => {
673                    tracing::info!(db = %name, %owner, "standing by; lease held elsewhere");
674                    node.standby
675                        .write()
676                        .unwrap_or_else(std::sync::PoisonError::into_inner)
677                        .insert(name);
678                }
679                Err(error) => return Err(error),
680            }
681        }
682        node.spawn_standby_poller();
683        node.spawn_scheduled_gc();
684        Ok(node)
685    }
686
687    /// The node's storage-service backend (blobs + roots).
688    #[must_use]
689    pub fn store(&self) -> &Arc<NodeStore> {
690        &self.store
691    }
692
693    /// Node configuration.
694    #[must_use]
695    pub fn config(&self) -> &NodeConfig {
696        &self.config
697    }
698
699    /// Process observability counters.
700    #[must_use]
701    pub const fn metrics(&self) -> &Metrics {
702        &self.metrics
703    }
704
705    fn spawn_scheduled_gc(self: &Arc<Self>) {
706        let Some(interval) = self.config.gc_interval else {
707            return;
708        };
709        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
710            // Embedded callers may construct an empty catalog before they
711            // enter a runtime. Process wiring opens nodes inside Tokio.
712            return;
713        };
714        let node = Arc::clone(self);
715        runtime.spawn(async move {
716            let mut ticker = tokio::time::interval(interval);
717            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
718            // `interval` ticks immediately; scheduled duties should wait a full interval.
719            ticker.tick().await;
720            loop {
721                ticker.tick().await;
722                if let Err(error) = node.gc_deleted().await {
723                    tracing::warn!(%error, "scheduled garbage collection failed");
724                }
725            }
726        });
727    }
728
729    /// Watch channel that reports a shutdown reason when the node deposes.
730    #[must_use]
731    pub fn shutdown_watch(&self) -> watch::Receiver<Option<String>> {
732        self.shutdown.subscribe()
733    }
734
735    /// Deposes a hosted database. In HA mode the database returns to
736    /// standby (the poller re-attempts takeover); otherwise the whole
737    /// process shuts down and a supervisor restart re-acquires or waits.
738    fn depose(&self, state: &DbState, reason: &str) {
739        state.deposed.store(true, Ordering::Release);
740        if self.config.ha {
741            tracing::warn!(db = %state.name, reason, "deposed; returning to standby");
742            self.dbs
743                .write()
744                .unwrap_or_else(std::sync::PoisonError::into_inner)
745                .remove(&state.name);
746            self.standby
747                .write()
748                .unwrap_or_else(std::sync::PoisonError::into_inner)
749                .insert(state.name.clone());
750        } else {
751            let _ = self
752                .shutdown
753                .send(Some(format!("database {:?}: {reason}", state.name)));
754        }
755    }
756
757    fn advertised(&self) -> &str {
758        self.config.advertise.as_deref().unwrap_or("")
759    }
760
761    /// Acquires the lease for `name`. In HA mode a held lease surfaces
762    /// immediately (the caller stands by); otherwise startup waits it out
763    /// up to the configured bound.
764    async fn acquire_lease(&self, name: &str) -> Result<Lease, NodeError> {
765        let deadline = now_unix_ms() + self.config.lease_wait_ms;
766        loop {
767            match lease::acquire(
768                self.store.as_ref(),
769                name,
770                &self.config.owner,
771                self.advertised(),
772                self.config.lease_ttl_ms,
773                now_unix_ms(),
774            )
775            .await
776            {
777                Ok(held) => return Ok(held),
778                Err(LeaseError::Held { .. }) if !self.config.ha && now_unix_ms() < deadline => {
779                    tokio::time::sleep(Duration::from_millis(200)).await;
780                }
781                Err(error) => return Err(error.into()),
782            }
783        }
784    }
785
786    async fn open_db(self: &Arc<Self>, name: &str) -> Result<Arc<DbState>, NodeError> {
787        let meta = self
788            .store
789            .get_root(&meta_root_name(name))
790            .await?
791            .ok_or_else(|| NodeError::UnknownDb(name.to_owned()))?;
792        let (schema, idents, interner) = codec::decode_metadata(&meta)?;
793        let root_name = db_root_name(name);
794        let current = self
795            .store
796            .get_root(&root_name)
797            .await?
798            .as_deref()
799            .and_then(DbRoot::decode);
800        if let Some(root) = &current
801            && root.format_version > corium_store::FORMAT_VERSION
802        {
803            return Err(NodeError::UnsupportedFormat {
804                found: root.format_version,
805                supported: corium_store::FORMAT_VERSION,
806            });
807        }
808        // Keys are resolved before the lease is taken: an encrypted database
809        // this process cannot open should fail loudly without first fencing
810        // out whoever can.
811        let manifest = load_key_manifest(self.store.as_ref(), name).await?;
812        let crypto = resolve_db_crypto(
813            name,
814            &self.store,
815            manifest.as_ref(),
816            self.config.keyring.as_ref(),
817        )
818        .await?;
819        // Acquisition rewrites the root record under our lease version, so
820        // it doubles as the fence bump: a deposed writer's pending root CAS
821        // now has stale expected bytes and must fail. It also preserves the
822        // published snapshot's recovery hints, so the root we re-read below
823        // carries everything index-root recovery needs.
824        let held = self.acquire_lease(name).await?;
825        // The log tail replay below happens strictly after the fence, so it
826        // observes every record a previous owner could ever have acked.
827        let log = self
828            .log_backend
829            .open(name, held.version, crypto.cipher.clone())
830            .await?;
831        let post_fence = self
832            .store
833            .get_root(&root_name)
834            .await?
835            .as_deref()
836            .and_then(DbRoot::decode);
837        let transactor = self
838            .recover_transactor(
839                name,
840                &schema,
841                &idents,
842                &interner,
843                post_fence.as_ref(),
844                &log,
845                crypto.store.as_ref(),
846            )
847            .await?;
848        let basis_t = transactor.db().basis_t();
849        let key_manifest_version = post_fence
850            .as_ref()
851            .map_or(0, |root| root.key_manifest_version);
852        let index_basis = post_fence.map_or(0, |root| root.index_basis_t);
853        let state = Arc::new(DbState {
854            name: name.to_owned(),
855            transactor,
856            log,
857            crypto: std::sync::RwLock::new(Arc::new(crypto)),
858            key_manifest_version: AtomicU64::new(key_manifest_version),
859            keys_unavailable: AtomicBool::new(false),
860            keys_fenced: AtomicBool::new(false),
861            fenced_active_epoch: AtomicU32::new(0),
862            naming: Mutex::new(Naming {
863                schema,
864                idents,
865                interner,
866            }),
867            commit: tokio::sync::Mutex::new(()),
868            pending: Mutex::new(VecDeque::new()),
869            broadcast: broadcast::channel(1024).0,
870            basis: watch::channel(basis_t).0,
871            index_basis: AtomicU64::new(index_basis),
872            index_policy: Mutex::new(IndexPolicy::from_config(&self.config)),
873            held_lease: Mutex::new(held),
874            deposed: AtomicBool::new(false),
875        });
876        self.spawn_maintenance(&state);
877        Ok(state)
878    }
879
880    /// Builds the recovered transactor for `open_db`.
881    ///
882    /// When the post-fence root publishes a current snapshot with recovery
883    /// hints, recovers from the index root plus the log tail — open time
884    /// proportional to the tail, not the whole history. Any missing hint
885    /// (a pre-recovery root, or a bare fence bump with no snapshot) or a
886    /// failure materializing the snapshot falls back to full-log replay,
887    /// which is always correct because the log is the source of truth.
888    #[allow(clippy::too_many_arguments)]
889    async fn recover_transactor(
890        &self,
891        name: &str,
892        schema: &Schema,
893        idents: &Idents,
894        interner: &KeywordInterner,
895        root: Option<&DbRoot>,
896        log: &Arc<dyn TransactionLog>,
897        store: &DbStore,
898    ) -> Result<EmbeddedTransactor, NodeError> {
899        // `next_entity_id == 0` is the "no hint" sentinel (see DbRoot); it and
900        // an absent snapshot both rule out the tail-only path.
901        if let Some(root) = root
902            && let Some(roots) = &root.roots
903            && root.next_entity_id != 0
904        {
905            match Self::load_current_snapshot(
906                store,
907                root,
908                &roots[IndexOrder::Eavt as usize],
909                schema,
910                idents,
911                interner,
912            )
913            .await
914            {
915                Ok(snapshot) => {
916                    return Ok(EmbeddedTransactor::recover_from_snapshot_async(
917                        snapshot,
918                        root.next_entity_id,
919                        root.last_tx_instant,
920                        Arc::clone(log),
921                    )
922                    .await?);
923                }
924                Err(error) => {
925                    tracing::warn!(
926                        db = %name,
927                        %error,
928                        "index-root recovery failed; falling back to full-log replay"
929                    );
930                }
931            }
932        }
933        let base = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
934        Ok(EmbeddedTransactor::recover_from_async(base, Arc::clone(log)).await?)
935    }
936
937    /// Materializes the current database value at a published index root from
938    /// its EAVT snapshot — the transactor-side counterpart of the peer's
939    /// bootstrap (`corium-peer`'s `load_current_snapshot`). Only current
940    /// facts are reconstructed; the log tail carries everything since.
941    async fn load_current_snapshot(
942        store: &DbStore,
943        root: &DbRoot,
944        eavt: &BlobId,
945        schema: &Schema,
946        idents: &Idents,
947        interner: &KeywordInterner,
948    ) -> Result<Db, StoreError> {
949        let datoms = Self::load_index_keys(store, eavt)
950            .await?
951            .into_iter()
952            .map(|key| Datom::from_key(IndexOrder::Eavt, &key))
953            .collect::<Result<Vec<_>, _>>()
954            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
955        Ok(Db::from_current_snapshot(
956            root.index_basis_t,
957            schema.clone(),
958            idents.clone(),
959            interner.clone(),
960            datoms,
961        ))
962    }
963
964    /// Reads one covering index's sorted key stream from the blob store: a
965    /// format-3 manifest's chunks in order, or a pre-format-3 flat blob.
966    async fn load_index_keys(store: &DbStore, id: &BlobId) -> Result<Vec<Vec<u8>>, StoreError> {
967        let blob = store
968            .get(id)
969            .await?
970            .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
971        if !is_index_manifest(&blob) {
972            return decode_segment_keys(&blob);
973        }
974        let mut keys = Vec::new();
975        for child in decode_index_manifest(&blob)? {
976            let chunk = store
977                .get(&child)
978                .await?
979                .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
980            keys.extend(decode_segment_keys(&chunk)?);
981        }
982        Ok(keys)
983    }
984
985    /// HA standby duty: at the lease-renewal cadence, rediscover databases
986    /// (including ones created on the active after this process started)
987    /// and attempt takeover of any whose lease has lapsed. Takeover is
988    /// ordinary startup — acquire (which fences), replay the log tail,
989    /// serve — per the crash-only design.
990    fn spawn_standby_poller(self: &Arc<Self>) {
991        if !self.config.ha {
992            return;
993        }
994        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
995            return;
996        };
997        let ttl = self.config.lease_ttl_ms;
998        let poll_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
999        let node = Arc::clone(self);
1000        runtime.spawn(async move {
1001            let mut ticker = tokio::time::interval(poll_every);
1002            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1003            loop {
1004                ticker.tick().await;
1005                if let Err(error) = node.standby_scan().await {
1006                    tracing::warn!(%error, "standby scan failed");
1007                }
1008            }
1009        });
1010    }
1011
1012    /// One standby pass: refresh the standby set from the catalog and try
1013    /// to take over lapsed leases.
1014    async fn standby_scan(self: &Arc<Self>) -> Result<(), NodeError> {
1015        let names: Vec<String> = self
1016            .store
1017            .list_roots("meta:")
1018            .await?
1019            .into_iter()
1020            .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
1021            .collect();
1022        {
1023            let mut standby = self
1024                .standby
1025                .write()
1026                .unwrap_or_else(std::sync::PoisonError::into_inner);
1027            standby.retain(|name| names.contains(name));
1028        }
1029        for name in names {
1030            if self
1031                .dbs
1032                .read()
1033                .unwrap_or_else(std::sync::PoisonError::into_inner)
1034                .contains_key(&name)
1035            {
1036                continue;
1037            }
1038            match self.open_db(&name).await {
1039                Ok(state) => {
1040                    tracing::info!(db = %name, owner = %self.config.owner, "standby took over write lease");
1041                    self.standby
1042                        .write()
1043                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1044                        .remove(&name);
1045                    self.dbs
1046                        .write()
1047                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1048                        .insert(name, state);
1049                }
1050                Err(NodeError::Lease(LeaseError::Held { .. })) => {
1051                    self.standby
1052                        .write()
1053                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1054                        .insert(name);
1055                }
1056                Err(error) => {
1057                    tracing::warn!(db = %name, %error, "standby takeover attempt failed");
1058                }
1059            }
1060        }
1061        Ok(())
1062    }
1063
1064    fn spawn_maintenance(self: &Arc<Self>, state: &Arc<DbState>) {
1065        let ttl = self.config.lease_ttl_ms;
1066        let renew_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
1067        // Lease renewal.
1068        let node = Arc::clone(self);
1069        let db = Arc::clone(state);
1070        tokio::spawn(async move {
1071            let mut ticker = tokio::time::interval(renew_every);
1072            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1073            loop {
1074                ticker.tick().await;
1075                if db.deposed.load(Ordering::Acquire) {
1076                    return;
1077                }
1078                // Serialize the root update and local held-lease update with
1079                // transaction lease checks so they cannot observe different
1080                // renewal generations and falsely depose this node.
1081                let _commit = db.commit.lock().await;
1082                let held = db.lease();
1083                let name = db.name.clone();
1084                let renewed =
1085                    lease::renew(node.store.as_ref(), &name, &held, ttl, now_unix_ms()).await;
1086                match renewed {
1087                    Ok(renewed) => {
1088                        *db.held_lease
1089                            .lock()
1090                            .unwrap_or_else(std::sync::PoisonError::into_inner) = renewed;
1091                    }
1092                    Err(LeaseError::Lost) => {
1093                        node.depose(&db, "write lease lost");
1094                        return;
1095                    }
1096                    Err(_) => {}
1097                }
1098                // The renewal already re-read the root, so this is where a
1099                // manifest change made elsewhere is cheapest to notice.
1100                if let Err(error) = node.refresh_keys_if_stale(&db).await {
1101                    tracing::warn!(db = %name, %error, "cannot reload storage keys");
1102                }
1103            }
1104        });
1105        self.spawn_indexing(state);
1106        // Heartbeats.
1107        let node = Arc::clone(self);
1108        let db = Arc::clone(state);
1109        tokio::spawn(async move {
1110            let mut ticker = tokio::time::interval(node.config.heartbeat_interval);
1111            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1112            loop {
1113                ticker.tick().await;
1114                if db.deposed.load(Ordering::Acquire) {
1115                    return;
1116                }
1117                let _ = db
1118                    .broadcast
1119                    .send(pb::subscribe_item::Item::Heartbeat(pb::Heartbeat {
1120                        basis_t: db.db().basis_t(),
1121                    }));
1122            }
1123        });
1124    }
1125
1126    /// Spawns the background indexing job, paced by the database's
1127    /// [`IndexPolicy`] (re-read every iteration so runtime overrides apply
1128    /// within a poll interval).
1129    fn spawn_indexing(self: &Arc<Self>, state: &Arc<DbState>) {
1130        // How often the job re-checks work and policy when the configured
1131        // interval is longer; runtime policy changes and pending work are
1132        // never noticed later than this.
1133        const POLICY_POLL: Duration = Duration::from_secs(1);
1134        let node = Arc::clone(self);
1135        let db = Arc::clone(state);
1136        tokio::spawn(async move {
1137            let mut published_at = Instant::now();
1138            let mut last_duration = Duration::ZERO;
1139            let mut published_len: Option<u64> = None;
1140            loop {
1141                let policy = db.index_policy();
1142                tokio::time::sleep(policy.interval.min(POLICY_POLL)).await;
1143                if db.deposed.load(Ordering::Acquire) {
1144                    return;
1145                }
1146                let snapshot = db.db();
1147                if snapshot.basis_t() <= db.index_basis() {
1148                    continue;
1149                }
1150                let recorded_len = u64::try_from(snapshot.recorded_len()).unwrap_or(u64::MAX);
1151                let pending = published_len.map(|len| recorded_len.saturating_sub(len));
1152                if !policy.due(published_at.elapsed(), last_duration, pending) {
1153                    continue;
1154                }
1155                match node.publish_db_indexes(&db).await {
1156                    Ok((_, duration)) => {
1157                        last_duration = duration;
1158                        // publish_db_indexes snapshots after this loop did,
1159                        // so the covered length is at least recorded_len; the
1160                        // underestimate only makes the next tail look bigger.
1161                        published_len = Some(recorded_len);
1162                    }
1163                    Err(NodeError::Deposed(_)) => return,
1164                    Err(_) => {}
1165                }
1166                published_at = Instant::now();
1167            }
1168        });
1169    }
1170
1171    /// Publishes `db`'s covering indexes now, returning the published index
1172    /// basis and how long the publication took. Serialized with garbage
1173    /// collection; deposes the database when the root is fenced by a newer
1174    /// lease.
1175    async fn publish_db_indexes(&self, db: &Arc<DbState>) -> Result<(u64, Duration), NodeError> {
1176        let _gc = self.gc_lock.lock().await;
1177        let version = db.lease().version;
1178        let root_name = db_root_name(&db.name);
1179        let started = Instant::now();
1180        let published = db
1181            .transactor
1182            .publish_indexes(db.store().as_ref(), &root_name, version)
1183            .await;
1184        let duration = started.elapsed();
1185        self.metrics.record_index(duration);
1186        match published {
1187            Ok(root) => {
1188                tracing::debug!(db = %db.name, index_basis_t = root.index_basis_t, "published indexes");
1189                db.index_basis.store(root.index_basis_t, Ordering::Release);
1190                let _ = db
1191                    .broadcast
1192                    .send(pb::subscribe_item::Item::IndexBasis(pb::IndexBasis {
1193                        index_basis_t: root.index_basis_t,
1194                    }));
1195                Ok((root.index_basis_t, duration))
1196            }
1197            Err(TransactError::Deposed { .. }) => {
1198                self.depose(db, "database root fenced by a newer lease");
1199                Err(NodeError::Deposed(db.name.clone()))
1200            }
1201            Err(error) => Err(error.into()),
1202        }
1203    }
1204
1205    /// Publishes indexes for `name` immediately, bypassing the pacing
1206    /// policy (the catalog `RequestIndex` RPC). Returns the resulting index
1207    /// basis; when the published indexes already cover every committed
1208    /// transaction, returns the current index basis without publishing.
1209    ///
1210    /// # Errors
1211    /// Returns [`NodeError`] when the database is unknown, this node is
1212    /// deposed or standing by, or publication fails.
1213    pub async fn request_index(&self, name: &str) -> Result<u64, NodeError> {
1214        let state = self.db_state(name).await?;
1215        if state.db().basis_t() <= state.index_basis() {
1216            return Ok(state.index_basis());
1217        }
1218        self.publish_db_indexes(&state)
1219            .await
1220            .map(|(index_basis_t, _)| index_basis_t)
1221    }
1222
1223    /// Applies per-database indexing-policy overrides at runtime, returning
1224    /// the policy now in effect. An empty update reads the current policy.
1225    ///
1226    /// # Errors
1227    /// Returns [`NodeError`] when the database is unknown or served
1228    /// elsewhere.
1229    pub async fn set_index_policy(
1230        &self,
1231        name: &str,
1232        update: IndexPolicyUpdate,
1233    ) -> Result<IndexPolicy, NodeError> {
1234        let state = self.db_state(name).await?;
1235        let mut policy = state
1236            .index_policy
1237            .lock()
1238            .unwrap_or_else(std::sync::PoisonError::into_inner);
1239        policy.apply(update);
1240        Ok(*policy)
1241    }
1242
1243    /// Looks up a hosted database.
1244    ///
1245    /// # Errors
1246    /// Returns [`NodeError::Standby`] when this HA node is standing by for
1247    /// the database, [`NodeError::UnknownDb`] when absent.
1248    pub async fn db_state(&self, name: &str) -> Result<Arc<DbState>, NodeError> {
1249        if let Some(state) = self
1250            .dbs
1251            .read()
1252            .unwrap_or_else(std::sync::PoisonError::into_inner)
1253            .get(name)
1254            .cloned()
1255        {
1256            return Ok(state);
1257        }
1258        if self.config.ha
1259            && self
1260                .standby
1261                .read()
1262                .unwrap_or_else(std::sync::PoisonError::into_inner)
1263                .contains(name)
1264        {
1265            let root = self
1266                .store
1267                .get_root(&db_root_name(name))
1268                .await?
1269                .as_deref()
1270                .and_then(DbRoot::decode);
1271            return Err(NodeError::Standby {
1272                db: name.to_owned(),
1273                owner: root.as_ref().map(|r| r.owner.clone()).unwrap_or_default(),
1274                endpoint: root.map(|r| r.owner_endpoint).unwrap_or_default(),
1275            });
1276        }
1277        Err(NodeError::UnknownDb(name.to_owned()))
1278    }
1279
1280    /// Databases this node currently stands by for (HA mode).
1281    #[must_use]
1282    pub fn standby_dbs(&self) -> Vec<String> {
1283        self.standby
1284            .read()
1285            .unwrap_or_else(std::sync::PoisonError::into_inner)
1286            .iter()
1287            .cloned()
1288            .collect()
1289    }
1290
1291    /// Creates a database with the supplied EDN schema forms; returns
1292    /// `false` when it already exists.
1293    ///
1294    /// `storage_key` names the key-encryption key the database's data keys are
1295    /// wrapped under, enabling encryption at rest. It is fixed here and
1296    /// forever: a database created without one stays unencrypted, and turning
1297    /// encryption on later is a backup and restore into a new database.
1298    ///
1299    /// # Errors
1300    /// Returns an error for invalid names/schema, an unresolvable storage key,
1301    /// or store failures.
1302    pub async fn create_db(
1303        self: &Arc<Self>,
1304        name: &str,
1305        schema_edn: &[u8],
1306        storage_key: Option<KeyId>,
1307    ) -> Result<bool, NodeError> {
1308        if !valid_db_name(name) {
1309            return Err(NodeError::InvalidName(name.to_owned()));
1310        }
1311        if self
1312            .dbs
1313            .read()
1314            .unwrap_or_else(std::sync::PoisonError::into_inner)
1315            .contains_key(name)
1316        {
1317            return Ok(false);
1318        }
1319        let forms = match codec::decode_edn(schema_edn)? {
1320            Edn::Vector(items) | Edn::List(items) => items,
1321            Edn::Nil => Vec::new(),
1322            other => {
1323                return Err(NodeError::BadRequest(format!(
1324                    "schema must be a vector of attribute maps, got {other}"
1325                )));
1326            }
1327        };
1328        let (schema, idents) = schema_from_edn(&forms)?;
1329        let meta = codec::encode_metadata(&schema, &idents, &KeywordInterner::default());
1330        // The manifest is written before the catalog entry: a crash between
1331        // them leaves an unreferenced key record, whereas the other order
1332        // would leave a catalogued database whose first write went out in the
1333        // clear.
1334        let encrypted = storage_key.is_some();
1335        if let Some(kek) = storage_key {
1336            self.create_key_manifest(name, kek).await?;
1337        }
1338        match self
1339            .store
1340            .cas_root(&meta_root_name(name), None, &meta)
1341            .await
1342        {
1343            Ok(()) => {}
1344            Err(StoreError::CasFailed { .. }) => {
1345                // Another node catalogued the name first; take our keys back
1346                // out so its manifest is the only one.
1347                if encrypted {
1348                    self.store.delete_root(&keys_root_name(name)).await?;
1349                }
1350                return Ok(false);
1351            }
1352            Err(error) => return Err(error.into()),
1353        }
1354        let state = self.open_db(name).await?;
1355        self.dbs
1356            .write()
1357            .unwrap_or_else(std::sync::PoisonError::into_inner)
1358            .insert(name.to_owned(), state);
1359        Ok(true)
1360    }
1361
1362    /// Forks `source` into a new database `target` whose state duplicates
1363    /// the source as of transaction `as_of_t` (`0` forks at the current
1364    /// basis). Only the log prefix is copied; the target replays it and
1365    /// publishes its own indexes, while blob segments dedupe by content
1366    /// address. Returns the fork's basis, or `None` when `target` already
1367    /// exists.
1368    ///
1369    /// # Errors
1370    /// Returns an error for an invalid target name, an unknown source, an
1371    /// `as_of_t` ahead of the source's basis, or store/log failures.
1372    pub async fn fork_db(
1373        self: &Arc<Self>,
1374        source: &str,
1375        target: &str,
1376        as_of_t: u64,
1377    ) -> Result<Option<u64>, NodeError> {
1378        if !valid_db_name(target) {
1379            return Err(NodeError::InvalidName(target.to_owned()));
1380        }
1381        if source == target {
1382            return Err(NodeError::BadRequest(
1383                "fork target must differ from the source".into(),
1384            ));
1385        }
1386        let state = self.db_state(source).await?;
1387        let basis = state.db().basis_t();
1388        let t = if as_of_t == 0 { basis } else { as_of_t };
1389        if t > basis {
1390            return Err(NodeError::BadRequest(format!(
1391                "as-of t {t} is ahead of {source:?} basis {basis}"
1392            )));
1393        }
1394        let _guard = self.fork_lock.lock().await;
1395        if self
1396            .dbs
1397            .read()
1398            .unwrap_or_else(std::sync::PoisonError::into_inner)
1399            .contains_key(target)
1400            || self
1401                .store
1402                .get_root(&meta_root_name(target))
1403                .await?
1404                .is_some()
1405            || self.log_backend.exists(target).await
1406        {
1407            return Ok(None);
1408        }
1409        // Capture the records before the metadata: meta is made durable
1410        // before any record that references it, so a meta read afterwards is
1411        // always a sufficient decode dictionary for the captured prefix.
1412        // Transaction numbers are contiguous from 1, so the prefix through
1413        // `t` is exactly the source's state at that basis.
1414        let records = state.log.tx_range_async(0, Some(t + 1)).await?;
1415        let meta = self
1416            .store
1417            .get_root(&meta_root_name(source))
1418            .await?
1419            .ok_or_else(|| NodeError::UnknownDb(source.to_owned()))?;
1420        // Write the log under version 0 so it sorts beneath the
1421        // lease-versioned file the target's first open creates, and publish
1422        // meta last — it is the catalog entry, so a crash mid-fork never
1423        // catalogs a target without its log.
1424        // A fork of an encrypted database is encrypted too, under its own
1425        // fresh data key: the log records below are re-sealed on the way in,
1426        // so the target shares no key material and no ciphertext with its
1427        // source, and its own KEK grant can be revoked independently.
1428        let target_crypto = match load_key_manifest(self.store.as_ref(), source).await? {
1429            Some(source_manifest) => {
1430                let manifest = self
1431                    .create_key_manifest(target, source_manifest.kek)
1432                    .await?;
1433                Some(
1434                    resolve_db_crypto(
1435                        target,
1436                        &self.store,
1437                        Some(&manifest),
1438                        self.config.keyring.as_ref(),
1439                    )
1440                    .await?,
1441                )
1442            }
1443            None => None,
1444        };
1445        let log = self
1446            .log_backend
1447            .open(
1448                target,
1449                0,
1450                target_crypto
1451                    .as_ref()
1452                    .and_then(|crypto| crypto.cipher.clone()),
1453            )
1454            .await?;
1455        for record in &records {
1456            log.append_async(record).await?;
1457        }
1458        drop(log);
1459        match self
1460            .store
1461            .cas_root(&meta_root_name(target), None, &meta)
1462            .await
1463        {
1464            Ok(()) => {}
1465            Err(StoreError::CasFailed { .. }) => {
1466                // Another node claimed the name first; discard our log copy
1467                // and the keys we minted for it.
1468                self.log_backend.delete_all(target).await?;
1469                if target_crypto.is_some() {
1470                    self.store.delete_root(&keys_root_name(target)).await?;
1471                }
1472                return Ok(None);
1473            }
1474            Err(error) => return Err(error.into()),
1475        }
1476        let state = self.open_db(target).await?;
1477        self.dbs
1478            .write()
1479            .unwrap_or_else(std::sync::PoisonError::into_inner)
1480            .insert(target.to_owned(), state);
1481        Ok(Some(t))
1482    }
1483
1484    /// Deletes a database: unhosts it, releases its lease, and removes its
1485    /// roots and log. Blobs remain until [`Self::gc_deleted`].
1486    ///
1487    /// # Errors
1488    /// Returns an error when roots or the log cannot be removed.
1489    pub async fn delete_db(&self, name: &str) -> Result<bool, NodeError> {
1490        let Some(state) = self
1491            .dbs
1492            .write()
1493            .unwrap_or_else(std::sync::PoisonError::into_inner)
1494            .remove(name)
1495        else {
1496            return Ok(false);
1497        };
1498        state.deposed.store(true, Ordering::Release);
1499        self.standby
1500            .write()
1501            .unwrap_or_else(std::sync::PoisonError::into_inner)
1502            .remove(name);
1503        self.store.delete_root(&db_root_name(name)).await?;
1504        self.store.delete_root(&meta_root_name(name)).await?;
1505        // The manifest goes with the database: leaving it would block
1506        // recreating the name, and its wrapped keys protect nothing once the
1507        // objects they encrypted are swept.
1508        self.store.delete_root(&keys_root_name(name)).await?;
1509        self.log_backend.delete_all(name).await?;
1510        Ok(true)
1511    }
1512
1513    /// Lists hosted databases.
1514    #[must_use]
1515    pub fn list_dbs(&self) -> Vec<String> {
1516        let mut names: Vec<String> = self
1517            .dbs
1518            .read()
1519            .unwrap_or_else(std::sync::PoisonError::into_inner)
1520            .keys()
1521            .cloned()
1522            .collect();
1523        names.sort();
1524        names
1525    }
1526
1527    /// The store this node marks `db`'s reachable blobs through.
1528    ///
1529    /// A hosted database already holds one; a database this node only stands
1530    /// by for is resolved from its manifest, because garbage collection is a
1531    /// node-wide duty that must not skip a database merely because another
1532    /// process holds its lease.
1533    async fn gc_store(&self, db: &str) -> Result<Arc<DbStore>, NodeError> {
1534        if let Some(state) = self
1535            .dbs
1536            .read()
1537            .unwrap_or_else(std::sync::PoisonError::into_inner)
1538            .get(db)
1539            .cloned()
1540        {
1541            return Ok(state.store());
1542        }
1543        let manifest = load_key_manifest(self.store.as_ref(), db).await?;
1544        Ok(resolve_db_crypto(
1545            db,
1546            &self.store,
1547            manifest.as_ref(),
1548            self.config.keyring.as_ref(),
1549        )
1550        .await?
1551        .store)
1552    }
1553
1554    /// Mints a database's first storage key and publishes its manifest.
1555    async fn create_key_manifest(&self, db: &str, kek: KeyId) -> Result<KeyManifest, NodeError> {
1556        let keyring = self.keyring()?;
1557        let manifest = KeyManifest::create(keyring.as_ref(), kek, now_unix_ms()).await?;
1558        match publish_key_manifest(self.store.as_ref(), db, None, &manifest).await {
1559            Ok(()) => Ok(manifest),
1560            Err(StoreError::CasFailed { .. }) => Err(NodeError::BadRequest(format!(
1561                "database {db:?} already has a key manifest; \
1562                 remove the stale {} root before recreating it",
1563                keys_root_name(db)
1564            ))),
1565            Err(error) => Err(error.into()),
1566        }
1567    }
1568
1569    fn keyring(&self) -> Result<&Arc<dyn Keyring>, NodeError> {
1570        self.config.keyring.as_ref().ok_or_else(|| {
1571            NodeError::BadRequest(
1572                "this transactor holds no storage keys; start it with --storage-key".into(),
1573            )
1574        })
1575    }
1576
1577    /// Reads a database's key manifest, current basis, and whether this node
1578    /// is actually operating on the keys the manifest names.
1579    ///
1580    /// # Errors
1581    /// Returns [`NodeError`] when the database is unknown or the manifest
1582    /// cannot be read.
1583    pub async fn key_status(&self, name: &str) -> Result<KeyStatus, NodeError> {
1584        let state = self.db_state(name).await?;
1585        Ok(KeyStatus {
1586            manifest: load_key_manifest(self.store.as_ref(), name).await?,
1587            basis_t: state.db().basis_t(),
1588            keys_unavailable: state.keys_unavailable(),
1589            keys_fenced: state.keys_fenced(),
1590        })
1591    }
1592
1593    /// Opens a new storage-key epoch that new writes use immediately.
1594    ///
1595    /// Nothing already stored is rewritten: old epochs stay readable and drain
1596    /// through ordinary re-indexing. The rotation runs under the database's
1597    /// commit lock, so the basis it records as the new epoch's opening is one
1598    /// no concurrent transaction can move, and no record is sealed between the
1599    /// manifest write and the cipher swap.
1600    ///
1601    /// # Errors
1602    /// Returns [`NodeError`] when the database is unknown or unencrypted, when
1603    /// the manifest changed under the rotation, or when the new key cannot be
1604    /// wrapped.
1605    pub async fn rotate_storage_key(&self, name: &str) -> Result<u32, NodeError> {
1606        let state = self.db_state(name).await?;
1607        let keyring = Arc::clone(self.keyring()?);
1608        let _commit = state.commit.lock().await;
1609        let previous = self.require_key_manifest(name).await?;
1610        let mut manifest = previous.clone();
1611        let epoch = manifest
1612            .rotate_storage_key(keyring.as_ref(), now_unix_ms(), state.db().basis_t())
1613            .await?;
1614        self.install_key_manifest(&state, Some(&previous), &manifest)
1615            .await?;
1616        tracing::info!(db = %name, epoch, "opened a new storage-key epoch");
1617        Ok(epoch)
1618    }
1619
1620    /// Re-wraps every storage key under `kek`, rewriting no data.
1621    ///
1622    /// # Errors
1623    /// Returns [`NodeError`] when the database is unknown or unencrypted, when
1624    /// either KEK cannot be resolved, or when the manifest changed underneath.
1625    pub async fn rewrap_keys(&self, name: &str, kek: KeyId) -> Result<(), NodeError> {
1626        let state = self.db_state(name).await?;
1627        let keyring = Arc::clone(self.keyring()?);
1628        let _commit = state.commit.lock().await;
1629        let previous = self.require_key_manifest(name).await?;
1630        let mut manifest = previous.clone();
1631        manifest.rewrap(keyring.as_ref(), kek.clone()).await?;
1632        self.install_key_manifest(&state, Some(&previous), &manifest)
1633            .await?;
1634        tracing::info!(db = %name, %kek, "re-wrapped storage keys under a new KEK");
1635        Ok(())
1636    }
1637
1638    async fn require_key_manifest(&self, name: &str) -> Result<KeyManifest, NodeError> {
1639        load_key_manifest(self.store.as_ref(), name)
1640            .await?
1641            .filter(|manifest| !manifest.storage_keys.is_empty())
1642            .ok_or_else(|| {
1643                NodeError::BadRequest(format!(
1644                    "database {name:?} is not encrypted; encryption is fixed at creation \
1645                     (corium db create --storage-key)"
1646                ))
1647            })
1648    }
1649
1650    /// Publishes a changed manifest, bumps the root's generation counter so
1651    /// other processes notice, and adopts the new keys here.
1652    ///
1653    /// The manifest is the durable record and goes first; the generation bump
1654    /// and the local swap follow. A crash between them leaves a manifest whose
1655    /// generation no root announces, which the next open resolves correctly
1656    /// because open reads the manifest itself.
1657    async fn install_key_manifest(
1658        &self,
1659        state: &Arc<DbState>,
1660        previous: Option<&KeyManifest>,
1661        manifest: &KeyManifest,
1662    ) -> Result<(), NodeError> {
1663        publish_key_manifest(self.store.as_ref(), &state.name, previous, manifest).await?;
1664        let version = self.bump_key_manifest_version(&state.name).await?;
1665        let crypto = state.crypto();
1666        let store =
1667            reload_db_crypto(&state.name, &crypto, manifest, self.config.keyring.as_ref()).await?;
1668        *state
1669            .crypto
1670            .write()
1671            .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(DbCrypto {
1672            store,
1673            cipher: crypto.cipher.clone(),
1674        });
1675        state.key_manifest_version.store(version, Ordering::Release);
1676        // A rotation performed here supersedes whatever alarm an earlier
1677        // failed reload raised: these keys came from this manifest.
1678        if state.clear_key_alarms() {
1679            self.metrics.record_keys_available();
1680        }
1681        Ok(())
1682    }
1683
1684    /// Increments `DbRoot::key_manifest_version`, the generation counter a
1685    /// running process watches to learn its key snapshot is stale.
1686    ///
1687    /// An index publication may install a new root between the read and the
1688    /// write, so the compare-and-set is retried against the newer root rather
1689    /// than failing the rotation whose manifest is already durable. The bump
1690    /// is a pure increment on whatever root is current, so re-reading loses
1691    /// nothing.
1692    async fn bump_key_manifest_version(&self, name: &str) -> Result<u64, NodeError> {
1693        const ATTEMPTS: usize = 5;
1694        let root_name = db_root_name(name);
1695        for attempt in 1..=ATTEMPTS {
1696            let stored = self.store.get_root(&root_name).await?;
1697            let mut root = stored
1698                .as_deref()
1699                .and_then(DbRoot::decode)
1700                .ok_or_else(|| NodeError::UnknownDb(name.to_owned()))?;
1701            root.key_manifest_version = root.key_manifest_version.saturating_add(1);
1702            match self
1703                .store
1704                .cas_root(&root_name, stored.as_deref(), &root.encode())
1705                .await
1706            {
1707                Ok(()) => return Ok(root.key_manifest_version),
1708                Err(StoreError::CasFailed { .. }) if attempt < ATTEMPTS => {}
1709                Err(error) => return Err(error.into()),
1710            }
1711        }
1712        unreachable!("the final attempt returns its result")
1713    }
1714
1715    /// Reloads `db`'s keys when another process changed the manifest.
1716    ///
1717    /// Called from the maintenance loop, so a rotation or re-wrap performed
1718    /// elsewhere — by an operator against a standby, or by the other half of
1719    /// an HA pair — is picked up without a restart.
1720    ///
1721    /// A failure here is not automatically fatal, and which failure it is
1722    /// decides that. Re-wrapping leaves the data keys themselves untouched, so
1723    /// a node that cannot resolve the new KEK still holds correct material and
1724    /// still writes under the epoch the manifest calls active; refusing its
1725    /// writes would turn a KMS outage into an outage. A *rotation* it cannot
1726    /// load is different in kind: every record it seals from then on is drawn
1727    /// under a key the manifest has closed, and the nonce budget — measured as
1728    /// the span of `t` between epochs — silently stops counting them. The
1729    /// epoch comparison that separates the two needs no key at all.
1730    async fn refresh_keys_if_stale(&self, state: &Arc<DbState>) -> Result<(), NodeError> {
1731        let Some(root) = self
1732            .store
1733            .get_root(&db_root_name(&state.name))
1734            .await?
1735            .as_deref()
1736            .and_then(DbRoot::decode)
1737        else {
1738            return Ok(());
1739        };
1740        if root.key_manifest_version == state.key_manifest_version.load(Ordering::Acquire) {
1741            return Ok(());
1742        }
1743        let Some(manifest) = load_key_manifest(self.store.as_ref(), &state.name).await? else {
1744            return Ok(());
1745        };
1746        let crypto = state.crypto();
1747        match reload_db_crypto(
1748            &state.name,
1749            &crypto,
1750            &manifest,
1751            self.config.keyring.as_ref(),
1752        )
1753        .await
1754        {
1755            Ok(store) => {
1756                *state
1757                    .crypto
1758                    .write()
1759                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(DbCrypto {
1760                    store,
1761                    cipher: crypto.cipher.clone(),
1762                });
1763                state
1764                    .key_manifest_version
1765                    .store(root.key_manifest_version, Ordering::Release);
1766                if state.clear_key_alarms() {
1767                    self.metrics.record_keys_available();
1768                }
1769                tracing::info!(
1770                    db = %state.name,
1771                    key_manifest_version = root.key_manifest_version,
1772                    "reloaded storage keys after a manifest change"
1773                );
1774                Ok(())
1775            }
1776            Err(error) => {
1777                self.raise_key_alarm(state, &crypto, &manifest, &error);
1778                Ok(())
1779            }
1780        }
1781    }
1782
1783    /// Records a failed key reload, escalating only when this node would
1784    /// otherwise keep writing under an epoch the manifest has closed.
1785    ///
1786    /// The maintenance loop calls this every renewal tick while the condition
1787    /// lasts, so both the log lines and the metric are edge-triggered: an
1788    /// operator sees one line naming the problem, not one per tick.
1789    fn raise_key_alarm(
1790        &self,
1791        state: &Arc<DbState>,
1792        crypto: &DbCrypto,
1793        manifest: &KeyManifest,
1794        error: &KeyWiringError,
1795    ) {
1796        let writing = crypto.store.storage_epoch();
1797        let active = manifest.active_storage_epoch();
1798        let was_alarmed = state.key_alarm_raised();
1799        let fenced =
1800            matches!((active, writing), (Some(active), Some(writing)) if active != writing);
1801        if fenced {
1802            let active = active.unwrap_or_default();
1803            state.fenced_active_epoch.store(active, Ordering::Release);
1804            if !state.keys_fenced.swap(true, Ordering::AcqRel) {
1805                tracing::error!(
1806                    db = %state.name,
1807                    active_epoch = active,
1808                    writing_epoch = writing.unwrap_or_default(),
1809                    %error,
1810                    "storage keys are fenced: the manifest opened an epoch this node cannot \
1811                     load, so writes are refused until --storage-key resolves it"
1812                );
1813            }
1814        } else if !state.keys_unavailable.swap(true, Ordering::AcqRel) {
1815            tracing::warn!(
1816                db = %state.name,
1817                %error,
1818                "cannot load the changed key manifest; continuing on the keys already held \
1819                 (they still open this database, and the epoch new writes use is unchanged)"
1820            );
1821        }
1822        if !was_alarmed {
1823            self.metrics.record_keys_unavailable();
1824        }
1825    }
1826
1827    /// Sweeps blobs unreachable from any live database root (including
1828    /// everything left behind by deleted databases and superseded indexes).
1829    ///
1830    /// # Errors
1831    /// Returns an error when the store cannot be enumerated or swept.
1832    pub async fn gc_deleted(&self) -> Result<u64, NodeError> {
1833        self.gc_deleted_with_retention(self.config.gc_retention)
1834            .await
1835    }
1836
1837    /// Sweeps unreachable blobs older than the caller-supplied retention.
1838    ///
1839    /// # Errors
1840    /// Returns an error when the store cannot be enumerated or swept.
1841    pub async fn gc_deleted_with_retention(&self, retention: Duration) -> Result<u64, NodeError> {
1842        let _gc = self.gc_lock.lock().await;
1843        // Marking reads blob *content* to find references, so each database's
1844        // roots are walked through that database's own store — an encrypted
1845        // one's manifests are ciphertext to everyone else. Sweeping is keyless:
1846        // it lists, stats, and deletes by id, so one marked set covers the
1847        // whole shared backend.
1848        let mut marked = std::collections::HashSet::new();
1849        for root_name in self.store.list_roots("db:").await? {
1850            let Some(root) = self
1851                .store
1852                .get_root(&root_name)
1853                .await?
1854                .as_deref()
1855                .and_then(DbRoot::decode)
1856            else {
1857                continue;
1858            };
1859            let Some(roots) = root.roots else {
1860                continue;
1861            };
1862            let db = root_name.strip_prefix("db:").unwrap_or(&root_name);
1863            let store = self.gc_store(db).await?;
1864            mark_reachable(
1865                store.as_ref(),
1866                roots,
1867                |_, bytes| corium_store::index_blob_children(bytes),
1868                &mut marked,
1869            )
1870            .await?;
1871        }
1872        let report =
1873            sweep_unmarked(self.store.as_ref(), &marked, retention, SystemTime::now()).await?;
1874        self.metrics
1875            .record_gc(report.swept as u64, report.retained as u64);
1876        tracing::info!(
1877            marked = report.marked,
1878            swept = report.swept,
1879            retained = report.retained,
1880            "garbage collection completed"
1881        );
1882        Ok(report.swept as u64)
1883    }
1884
1885    /// Validates, appends, applies, and reports one transaction supplied as
1886    /// composite-encoded EDN transaction forms.
1887    ///
1888    /// # Errors
1889    /// Returns [`NodeError`] for decode/validation failures, lease loss, or
1890    /// storage failures.
1891    pub async fn transact(
1892        &self,
1893        name: &str,
1894        tx_data: &[u8],
1895    ) -> Result<pb::TransactResponse, NodeError> {
1896        self.transact_at(name, tx_data, None).await
1897    }
1898
1899    /// Applies one transaction only if `expected_basis_t` is still current.
1900    ///
1901    /// An absent expectation preserves the ordinary Corium transaction
1902    /// behavior. Conditional callers receive [`NodeError::BasisMismatch`]
1903    /// before preparation or durability when another transaction won first.
1904    ///
1905    /// # Errors
1906    /// Returns [`NodeError`] for a stale basis, decode/validation failures,
1907    /// lease loss, or storage failures.
1908    pub async fn transact_at(
1909        &self,
1910        name: &str,
1911        tx_data: &[u8],
1912        expected_basis_t: Option<u64>,
1913    ) -> Result<pb::TransactResponse, NodeError> {
1914        let started = Instant::now();
1915        let result = self
1916            .transact_inner(name, tx_data, expected_basis_t)
1917            .instrument(tracing::info_span!("transact", db = name))
1918            .await;
1919        self.metrics.record_tx(started.elapsed(), result.is_ok());
1920        if let Err(error) = &result {
1921            tracing::warn!(%error, "transaction failed");
1922        }
1923        result
1924    }
1925
1926    async fn transact_inner(
1927        &self,
1928        name: &str,
1929        tx_data: &[u8],
1930        expected_basis_t: Option<u64>,
1931    ) -> Result<pb::TransactResponse, NodeError> {
1932        let state = self.db_state(name).await?;
1933        let decoded = codec::decode_edn(tx_data)?;
1934        let forms = decoded
1935            .as_seq()
1936            .ok_or_else(|| NodeError::BadRequest("tx-data must be a vector".into()))?
1937            .to_vec();
1938        // Enqueue for the next group-commit flush, then contend to lead one.
1939        // Whichever caller holds `commit` drains the queue and commits the
1940        // whole run under one durable append and one ownership fence, then
1941        // answers every queued caller — so batching is invisible to clients:
1942        // each transaction keeps its own `t`, report, and ack.
1943        let (resp_tx, mut resp_rx) = oneshot::channel();
1944        state
1945            .pending
1946            .lock()
1947            .unwrap_or_else(std::sync::PoisonError::into_inner)
1948            .push_back(CommitRequest {
1949                forms,
1950                expected_basis_t,
1951                resp: resp_tx,
1952            });
1953        let queued = self.metrics.queue_waiter();
1954        loop {
1955            let commit = state.commit.lock().await;
1956            // A prior leader may already have committed this request.
1957            match resp_rx.try_recv() {
1958                Ok(result) => {
1959                    drop(commit);
1960                    drop(queued);
1961                    return result;
1962                }
1963                Err(oneshot::error::TryRecvError::Empty) => {}
1964                Err(oneshot::error::TryRecvError::Closed) => {
1965                    drop(commit);
1966                    drop(queued);
1967                    return Err(NodeError::GroupCommit("commit response dropped".into()));
1968                }
1969            }
1970            // Lead a flush of the pending queue (which contains this request).
1971            self.flush_commit_batch(&state).await;
1972            drop(commit);
1973            match resp_rx.try_recv() {
1974                Ok(result) => {
1975                    drop(queued);
1976                    return result;
1977                }
1978                // A naming change ends a batch before this request; the
1979                // remainder was requeued, so loop and lead the next flush.
1980                Err(oneshot::error::TryRecvError::Empty) => {}
1981                Err(oneshot::error::TryRecvError::Closed) => {
1982                    drop(queued);
1983                    return Err(NodeError::GroupCommit("commit response dropped".into()));
1984                }
1985            }
1986        }
1987    }
1988
1989    /// Group-commit flush, run by the batch leader while it holds
1990    /// `state.commit`: drains the pending queue, prepares the run against a
1991    /// staging value (so each transaction still validates against its
1992    /// predecessors), makes the whole run durable with one batched append and
1993    /// one post-append ownership fence, then installs it and answers every
1994    /// caller. A transaction that interns new keywords ends the batch — so a
1995    /// later transaction never depends on names not yet durable — and the
1996    /// unprepared remainder is requeued for the next flush.
1997    #[allow(clippy::too_many_lines)]
1998    async fn flush_commit_batch(&self, state: &Arc<DbState>) {
1999        let max_count = self.config.max_commit_batch.max(1);
2000        let max_bytes = self.config.max_commit_batch_bytes;
2001
2002        let mut batch: VecDeque<CommitRequest> = {
2003            let mut pending = state
2004                .pending
2005                .lock()
2006                .unwrap_or_else(std::sync::PoisonError::into_inner);
2007            std::mem::take(&mut *pending)
2008        };
2009        if batch.is_empty() {
2010            return;
2011        }
2012        // Refuse before preparing anything when this node's storage keys are
2013        // fenced. Unlike the ownership fence below, this is a local flag with
2014        // no round trip, and it has to come first: a record sealed under a
2015        // closed epoch is durable and uncounted the moment it is appended.
2016        if let Some(error) = state.keys_fenced_error() {
2017            for request in batch {
2018                let _ = request
2019                    .resp
2020                    .send(Err(batch_abort_error(&state.name, &error)));
2021            }
2022            return;
2023        }
2024        // No pre-append ownership check on the common path: the post-append
2025        // fence below is the safety-critical one, and skipping the pre-check
2026        // removes a lease round trip per batch. A deposed leader still prepares
2027        // and appends (harmlessly, under its old lease version, which the
2028        // successor's cutoff discards), then the fence refuses to acknowledge.
2029        // The one exception is a batch that interns new keywords, which
2030        // publishes the unfenced metadata root — that path re-checks ownership
2031        // before writing it, below.
2032        let now_ms = now_unix_ms();
2033        let mut cursor = state.transactor.batch_cursor();
2034        let mut resps: Vec<oneshot::Sender<Result<pb::TransactResponse, NodeError>>> = Vec::new();
2035        let mut prepared: Vec<Prepared> = Vec::new();
2036        let mut batch_bytes: usize = 0;
2037        let mut measure = Vec::new();
2038        let mut naming_changed = false;
2039        while let Some(request) = batch.pop_front() {
2040            if let Some(expected) = request.expected_basis_t {
2041                let actual = cursor.db().basis_t();
2042                if actual != expected {
2043                    let _ = request
2044                        .resp
2045                        .send(Err(NodeError::BasisMismatch { expected, actual }));
2046                    continue;
2047                }
2048            }
2049            // Expand `:db/fn` against the staging value, so each transaction
2050            // sees the earlier ones in the batch. The expander blocks up to
2051            // its budget, so it runs off the async workers.
2052            let forms = if let Some(expander) = &self.config.tx_fn_expander {
2053                let expander = Arc::clone(expander);
2054                let db = cursor.db().clone();
2055                let forms = request.forms;
2056                match tokio::task::spawn_blocking(move || expander.expand(&db, forms)).await {
2057                    Ok(Ok(forms)) => forms,
2058                    Ok(Err(message)) => {
2059                        let _ = request.resp.send(Err(NodeError::BadRequest(message)));
2060                        continue;
2061                    }
2062                    Err(error) => {
2063                        let _ = request.resp.send(Err(NodeError::BadRequest(format!(
2064                            "expander task failed: {error}"
2065                        ))));
2066                        continue;
2067                    }
2068                }
2069            } else {
2070                request.forms
2071            };
2072            // Convert forms, interning new keyword values into the shared
2073            // naming, against the staging value.
2074            let (items, this_changed) = {
2075                let mut naming = state
2076                    .naming
2077                    .lock()
2078                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2079                let before = naming.interner.len();
2080                match tx_items_from_edn(cursor.db(), &mut naming.interner, &forms) {
2081                    Ok(items) => (items, naming.interner.len() > before),
2082                    Err(error) => {
2083                        drop(naming);
2084                        let _ = request.resp.send(Err(error.into()));
2085                        continue;
2086                    }
2087                }
2088            };
2089            match cursor.prepare(items, now_ms) {
2090                Ok(prep) => {
2091                    measure.clear();
2092                    let _ = corium_log::append_framed_record(&mut measure, &prep.record);
2093                    batch_bytes += measure.len();
2094                    resps.push(request.resp);
2095                    prepared.push(prep);
2096                }
2097                Err(error) => {
2098                    let _ = request.resp.send(Err(NodeError::Transact(error.into())));
2099                    continue;
2100                }
2101            }
2102            if this_changed {
2103                naming_changed = true;
2104                break;
2105            }
2106            // Cap the batch by transaction count or accumulated encoded size.
2107            // The transaction that crosses the byte budget is already included,
2108            // so at least one — even a single oversized transaction — commits.
2109            if prepared.len() >= max_count || batch_bytes >= max_bytes {
2110                break;
2111            }
2112        }
2113        // Requeue the unprepared remainder at the front of the queue, in order.
2114        if !batch.is_empty() {
2115            let mut pending = state
2116                .pending
2117                .lock()
2118                .unwrap_or_else(std::sync::PoisonError::into_inner);
2119            while let Some(request) = batch.pop_back() {
2120                pending.push_front(request);
2121            }
2122        }
2123        if prepared.is_empty() {
2124            return;
2125        }
2126        // Snapshot the interner for response encoding; capture the idents only
2127        // when naming changed, to carry into `update_naming` after install.
2128        let interner = {
2129            let naming = state
2130                .naming
2131                .lock()
2132                .unwrap_or_else(std::sync::PoisonError::into_inner);
2133            naming.interner.clone()
2134        };
2135        let changed_idents = if naming_changed {
2136            // Publishing new keyword names writes the metadata root, which is
2137            // not lease-fenced, so verify ownership before writing it. This is
2138            // the one lease check the common (no-new-keyword) path skips.
2139            if let Err(error) = state.check_lease(self.store.as_ref()).await {
2140                if matches!(error, NodeError::Deposed(_)) {
2141                    self.depose(state, "write lease lost before metadata publish");
2142                }
2143                for resp in resps {
2144                    let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
2145                }
2146                return;
2147            }
2148            let (idents, schema) = {
2149                let naming = state
2150                    .naming
2151                    .lock()
2152                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2153                (naming.idents.clone(), naming.schema.clone())
2154            };
2155            // New keyword names must be durable before the datoms that
2156            // reference them; recovery decodes the log against this meta.
2157            let meta = codec::encode_metadata(&schema, &idents, &interner);
2158            loop {
2159                let cas = match self.store.get_root(&meta_root_name(&state.name)).await {
2160                    Ok(current) => {
2161                        self.store
2162                            .cas_root(&meta_root_name(&state.name), current.as_deref(), &meta)
2163                            .await
2164                    }
2165                    Err(error) => Err(error),
2166                };
2167                match cas {
2168                    Ok(()) => break,
2169                    Err(StoreError::CasFailed { .. }) => {}
2170                    Err(error) => {
2171                        let error = NodeError::Store(error);
2172                        for resp in resps {
2173                            let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
2174                        }
2175                        return;
2176                    }
2177                }
2178            }
2179            Some(idents)
2180        } else {
2181            None
2182        };
2183        // One durable append for the whole batch — the commit point.
2184        let records: Vec<TxRecord> = prepared.iter().map(|prep| prep.record.clone()).collect();
2185        if let Err(error) = state.log.append_batch_async(&records).await {
2186            let error = NodeError::Log(error);
2187            for resp in resps {
2188                let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
2189            }
2190            return;
2191        }
2192        // Install in memory now — while still holding `commit`, before the
2193        // fence — so the live value stays in lock-step with the durable log
2194        // regardless of the fence outcome (exactly as the single-transaction
2195        // path applied before its fence). Installing advances the value and
2196        // notifies in-process subscribers; it does not acknowledge callers.
2197        let reports = state.transactor.install_batch(cursor, prepared);
2198        if let Some(idents) = changed_idents {
2199            state.transactor.update_naming(idents, interner.clone());
2200        }
2201        // Post-append fence gates only the acknowledgement and the peer
2202        // stream: ack a batch only if ownership was intact after it became
2203        // durable. A takeover that raced the append replayed the log *after*
2204        // rewriting the root record, so a batch we ack is provably in the
2205        // successor's replay; one we refuse is discarded by the successor's
2206        // cutoff — and because the whole batch is one atomic object, the cutoff
2207        // keeps all or none of it. One fence covers the batch (see
2208        // log-and-transactor.md).
2209        if let Err(error) = state.check_lease(self.store.as_ref()).await {
2210            if matches!(error, NodeError::Deposed(_)) {
2211                self.depose(state, "write lease lost after durable append");
2212            }
2213            for resp in resps {
2214                let _ = resp.send(Err(batch_abort_error(&state.name, &error)));
2215            }
2216            return;
2217        }
2218        let mut last_t = 0;
2219        for (resp, report) in resps.into_iter().zip(reports) {
2220            let t = report.db_after.basis_t();
2221            last_t = last_t.max(t);
2222            let datoms = match codec::encode_datoms(&report.tx.datoms, &interner) {
2223                Ok(datoms) => datoms,
2224                Err(error) => {
2225                    let _ = resp.send(Err(NodeError::Codec(error)));
2226                    continue;
2227                }
2228            };
2229            let tempids = codec::encode_edn(&Edn::Map(
2230                report
2231                    .tx
2232                    .tempids
2233                    .iter()
2234                    .map(|(tempid, eid)| {
2235                        (
2236                            Edn::Str(tempid.clone()),
2237                            Edn::Long(i64::try_from(eid.raw()).unwrap_or(i64::MAX)),
2238                        )
2239                    })
2240                    .collect(),
2241            ));
2242            let _ = state
2243                .broadcast
2244                .send(pb::subscribe_item::Item::Report(pb::TxReport {
2245                    t,
2246                    tx_instant: report.tx_instant,
2247                    datoms: datoms.clone(),
2248                }));
2249            let _ = resp.send(Ok(pb::TransactResponse {
2250                basis_before: report.db_before.basis_t(),
2251                basis_t: t,
2252                tx_instant: report.tx_instant,
2253                tempids,
2254                tx_data: datoms,
2255            }));
2256        }
2257        if last_t > 0 {
2258            let _ = state.basis.send(last_t);
2259        }
2260    }
2261
2262    /// Current status for a database.
2263    ///
2264    /// # Errors
2265    /// Returns [`NodeError::UnknownDb`] when absent.
2266    pub async fn status(&self, name: &str) -> Result<pb::StatusResponse, NodeError> {
2267        let state = self.db_state(name).await?;
2268        let db = state.db();
2269        let counts = db.stats();
2270        let held = state.lease();
2271        let metrics = self.metrics.snapshot();
2272        Ok(pb::StatusResponse {
2273            basis_t: db.basis_t(),
2274            index_basis_t: state.index_basis(),
2275            lease_owner: held.owner,
2276            lease_version: held.version,
2277            lease_expires_unix_ms: held.expires_unix_ms,
2278            datom_count: counts.datoms as u64,
2279            entity_count: counts.entities as u64,
2280            attribute_count: counts.attributes as u64,
2281            transaction_count: metrics.tx_total,
2282            transaction_failure_count: metrics.tx_failed,
2283            transaction_queue_depth: metrics.queue_depth,
2284            index_lag: db.basis_t().saturating_sub(state.index_basis()),
2285            indexing_runs: metrics.index_runs,
2286            gc_runs: metrics.gc_runs,
2287            gc_swept_blobs: metrics.gc_swept,
2288            lease_owner_endpoint: held.endpoint,
2289        })
2290    }
2291
2292    /// Fixes the current transaction basis and returns the connection details
2293    /// an administrative client needs to replay the underlying storage log
2294    /// independently.
2295    ///
2296    /// # Errors
2297    /// Returns [`NodeError::UnknownDb`] when absent, or a bad-request error
2298    /// when local connection details cannot be represented on the wire.
2299    pub async fn backup_info(&self, name: &str) -> Result<pb::GetStorageInfoResponse, NodeError> {
2300        let state = self.db_state(name).await?;
2301        // Serialize with the tiny commit critical section so the checkpoint
2302        // cannot observe a batch after its durable append but before its
2303        // ownership fence and acknowledgement decision.
2304        let basis_t = {
2305            let _commit = state.commit.lock().await;
2306            state.check_lease(self.store.as_ref()).await?;
2307            state.db().basis_t()
2308        };
2309        // Credential generation may call AWS STS. Do that after fixing the
2310        // checkpoint and releasing the commit lock so a slow identity service
2311        // never stalls transactions.
2312        let storage = self
2313            .config
2314            .store
2315            .connection_info(&self.config.data_dir, &self.config.storage_info)
2316            .await
2317            .map_err(NodeError::BadRequest)?;
2318        Ok(pb::GetStorageInfoResponse {
2319            basis_t,
2320            storage: Some(storage),
2321        })
2322    }
2323
2324    /// Releases every held write lease (graceful shutdown): the record is
2325    /// expired in place so a standby's next poll takes over immediately
2326    /// instead of waiting out the TTL. Hosted databases stop accepting
2327    /// work first, so nothing commits after its lease is gone.
2328    pub async fn release_leases(&self) {
2329        let states: Vec<Arc<DbState>> = self
2330            .dbs
2331            .write()
2332            .unwrap_or_else(std::sync::PoisonError::into_inner)
2333            .drain()
2334            .map(|(_, state)| state)
2335            .collect();
2336        for state in states {
2337            state.deposed.store(true, Ordering::Release);
2338            if let Err(error) =
2339                lease::release(self.store.as_ref(), &state.name, &state.lease()).await
2340            {
2341                tracing::warn!(db = %state.name, %error, "lease release failed at shutdown");
2342            }
2343        }
2344    }
2345
2346    /// Waits until the database basis reaches `t`, returning the basis seen.
2347    ///
2348    /// # Errors
2349    /// Returns [`NodeError::UnknownDb`] when absent.
2350    pub async fn sync(&self, name: &str, t: u64) -> Result<u64, NodeError> {
2351        let state = self.db_state(name).await?;
2352        let mut basis = state.basis_watch();
2353        let target = if t == 0 { *basis.borrow() } else { t };
2354        loop {
2355            let current = *basis.borrow();
2356            if current >= target {
2357                return Ok(current);
2358            }
2359            if basis.changed().await.is_err() {
2360                return Ok(*basis.borrow());
2361            }
2362        }
2363    }
2364}
2365
2366#[cfg(test)]
2367mod tests {
2368    use super::IndexPolicy;
2369    use std::time::Duration;
2370
2371    fn pacing(interval_ms: u64, backoff: u32, threshold: u64, deadline_ms: u64) -> IndexPolicy {
2372        IndexPolicy {
2373            interval: Duration::from_millis(interval_ms),
2374            backoff,
2375            tail_threshold: threshold,
2376            tail_deadline: Duration::from_millis(deadline_ms),
2377        }
2378    }
2379
2380    #[test]
2381    fn base_interval_gates_publication() {
2382        let pacing = pacing(100, 4, 0, 60_000);
2383        assert!(!pacing.due(Duration::from_millis(99), Duration::ZERO, None));
2384        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
2385    }
2386
2387    #[test]
2388    fn backoff_stretches_the_floor_past_the_interval() {
2389        let pacing = pacing(100, 4, 0, 60_000);
2390        let last = Duration::from_millis(300);
2391        assert!(!pacing.due(Duration::from_millis(1_199), last, Some(10)));
2392        assert!(pacing.due(Duration::from_millis(1_200), last, Some(10)));
2393    }
2394
2395    #[test]
2396    fn zero_backoff_keeps_the_base_interval() {
2397        let pacing = pacing(100, 0, 0, 60_000);
2398        assert!(pacing.due(
2399            Duration::from_millis(100),
2400            Duration::from_secs(30),
2401            Some(10)
2402        ));
2403    }
2404
2405    #[test]
2406    fn fast_publications_leave_the_interval_untouched() {
2407        let pacing = pacing(5_000, 4, 0, 60_000);
2408        assert!(pacing.due(Duration::from_secs(5), Duration::from_millis(3), Some(1)));
2409    }
2410
2411    #[test]
2412    fn small_tail_defers_until_the_deadline() {
2413        let pacing = pacing(100, 4, 1_000, 60_000);
2414        assert!(!pacing.due(Duration::from_secs(30), Duration::ZERO, Some(999)));
2415        assert!(pacing.due(Duration::from_secs(60), Duration::ZERO, Some(999)));
2416    }
2417
2418    #[test]
2419    fn tail_at_threshold_publishes_at_base_pacing() {
2420        let pacing = pacing(100, 4, 1_000, 60_000);
2421        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, Some(1_000)));
2422    }
2423
2424    #[test]
2425    fn unknown_tail_publishes_at_base_pacing() {
2426        let pacing = pacing(100, 4, 1_000, 60_000);
2427        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
2428    }
2429
2430    #[test]
2431    fn deadline_never_overrides_the_backoff_floor() {
2432        let pacing = pacing(100, 4, 1_000, 200);
2433        let last = Duration::from_millis(300);
2434        assert!(!pacing.due(Duration::from_millis(400), last, Some(1)));
2435        assert!(pacing.due(Duration::from_millis(1_200), last, Some(1)));
2436    }
2437}