Skip to main content

corium_transactor/
node.rs

1//! The transactor as a process: multi-database state, durable naming,
2//! lease acquisition/renewal, background indexing, tx-report fan-out, and
3//! high-availability standby takeover.
4
5use std::collections::{BTreeSet, HashMap};
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
10
11use corium_core::{Datom, IndexOrder, KeywordInterner, Schema};
12use corium_db::{Db, Idents};
13use corium_log::{LogError, TransactionLog, TxRecord};
14use corium_protocol::codec::{self, CodecError};
15use corium_protocol::pb;
16use corium_protocol::schemaform::{SchemaFormError, schema_from_edn};
17use corium_protocol::txforms::{TxFormError, tx_items_from_edn};
18use corium_query::edn::Edn;
19use corium_store::{
20    BlobId, BlobStore, RootStore, StoreError, decode_index_manifest, decode_segment_keys,
21    is_index_manifest, mark_and_sweep_retained, meta_root_name,
22};
23use thiserror::Error;
24use tokio::sync::{broadcast, watch};
25use tracing::Instrument;
26
27use crate::backend::{LogBackend, NodeStore, StoreSpec};
28use crate::lease::{self, Lease, LeaseError};
29use crate::metrics::Metrics;
30use crate::{DbRoot, EmbeddedTransactor, TransactError, db_root_name};
31
32/// Expands user database-function invocations in boundary EDN transaction
33/// forms before native conversion. Implemented by `corium-cljrs` (the
34/// sandboxed Clojurust host, ADR-0008) and injected by the process wiring;
35/// the transactor itself stays free of cljrs dependencies.
36pub trait TxFnExpander: Send + Sync {
37    /// Rewrites `forms` with every `[:my/fn arg…]` invocation replaced by
38    /// the function's returned tx-data (recursively).
39    ///
40    /// # Errors
41    /// Returns a display message when a function is missing, rejected by
42    /// the sandbox, fails, or exceeds its budget; the transaction aborts.
43    fn expand(&self, db: &Db, forms: Vec<Edn>) -> Result<Vec<Edn>, String>;
44}
45
46/// Node process configuration.
47#[derive(Clone)]
48pub struct NodeConfig {
49    /// Storage-service backend for blobs and roots (`mem`, `fs`, or Turso).
50    pub store: StoreSpec,
51    /// Data directory holding the filesystem blob/root store (for the `fs`
52    /// backend) and the transaction logs (for every non-`mem` backend).
53    pub data_dir: PathBuf,
54    /// Stable owner identity for lease records.
55    pub owner: String,
56    /// Lease time-to-live in milliseconds.
57    pub lease_ttl_ms: i64,
58    /// How long to wait for a held lease to expire before giving up.
59    pub lease_wait_ms: i64,
60    /// High-availability mode: when another owner holds a database's lease,
61    /// stand by and take over on expiry instead of failing startup, and on
62    /// depose return to standby instead of shutting the process down.
63    pub ha: bool,
64    /// Client endpoint advertised in the lease for peer lease-holder
65    /// rediscovery (e.g. `http://transactor-a:4334`).
66    pub advertise: Option<String>,
67    /// Interval between background index publications.
68    pub index_interval: Duration,
69    /// Minimum wait before the next index publication, as a multiple of the
70    /// previous publication's duration. Publications currently rewrite every
71    /// index in full, so this stretches the effective interval as the
72    /// database grows, bounding the share of time and storage bandwidth
73    /// spent republishing to at most `1/(1+n)`; 0 disables the backoff.
74    pub index_backoff: u32,
75    /// Pending log-tail growth (recorded datoms) below which a due
76    /// publication is deferred, so trickle writes coalesce instead of
77    /// rewriting every index; 0 publishes any pending work.
78    pub index_tail_threshold: u64,
79    /// Longest a pending below-threshold tail may defer publication.
80    pub index_tail_deadline: Duration,
81    /// Interval between heartbeats on subscription streams.
82    pub heartbeat_interval: Duration,
83    /// Interval between scheduled garbage-collection duties; `None` disables it.
84    pub gc_interval: Option<Duration>,
85    /// Minimum age of an unreachable blob before scheduled/manual online GC.
86    pub gc_retention: Duration,
87    /// Optional database-function expander (`:db/fn` support).
88    pub tx_fn_expander: Option<Arc<dyn TxFnExpander>>,
89}
90
91impl std::fmt::Debug for NodeConfig {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("NodeConfig")
94            .field("store", &self.store)
95            .field("data_dir", &self.data_dir)
96            .field("owner", &self.owner)
97            .field("lease_ttl_ms", &self.lease_ttl_ms)
98            .field("lease_wait_ms", &self.lease_wait_ms)
99            .field("ha", &self.ha)
100            .field("advertise", &self.advertise)
101            .field("index_interval", &self.index_interval)
102            .field("index_backoff", &self.index_backoff)
103            .field("index_tail_threshold", &self.index_tail_threshold)
104            .field("index_tail_deadline", &self.index_tail_deadline)
105            .field("heartbeat_interval", &self.heartbeat_interval)
106            .field("gc_interval", &self.gc_interval)
107            .field("gc_retention", &self.gc_retention)
108            .field("tx_fn_expander", &self.tx_fn_expander.is_some())
109            .finish()
110    }
111}
112
113impl NodeConfig {
114    /// Sensible defaults for a data directory.
115    #[must_use]
116    pub fn new(data_dir: PathBuf) -> Self {
117        Self {
118            store: StoreSpec::Fs,
119            data_dir,
120            owner: format!(
121                "transactor-{}",
122                std::env::var("HOSTNAME").unwrap_or_else(|_| "local".into())
123            ),
124            lease_ttl_ms: 5_000,
125            lease_wait_ms: 15_000,
126            ha: false,
127            advertise: None,
128            index_interval: Duration::from_secs(5),
129            index_backoff: 4,
130            index_tail_threshold: 0,
131            index_tail_deadline: Duration::from_secs(60),
132            heartbeat_interval: Duration::from_secs(10),
133            gc_interval: Some(Duration::from_secs(60 * 60)),
134            gc_retention: Duration::from_secs(72 * 60 * 60),
135            tx_fn_expander: None,
136        }
137    }
138}
139
140/// Pacing policy for one database's background indexing job.
141///
142/// A publication is due when the adaptive floor has elapsed — the base
143/// interval stretched by a multiple of the previous publication's duration,
144/// which bounds the indexing duty cycle as full republication gets slower —
145/// and the pending log tail is either large enough to be worth rewriting
146/// every index or old enough that deferring it further would leave cold
147/// readers and backups too far behind.
148///
149/// Every database starts from the node's [`NodeConfig`] pacing fields; the
150/// catalog `SetIndexPolicy` RPC (or
151/// [`TransactorNode::set_index_policy`]) overrides it at runtime.
152#[derive(Clone, Copy, Debug, Eq, PartialEq)]
153pub struct IndexPolicy {
154    /// Base interval between publications ([`NodeConfig::index_interval`]).
155    pub interval: Duration,
156    /// Duty-cycle multiplier on the previous publication's duration
157    /// ([`NodeConfig::index_backoff`]).
158    pub backoff: u32,
159    /// Pending-datom count below which a due publication is deferred
160    /// ([`NodeConfig::index_tail_threshold`]).
161    pub tail_threshold: u64,
162    /// Longest a below-threshold tail may defer publication
163    /// ([`NodeConfig::index_tail_deadline`]).
164    pub tail_deadline: Duration,
165}
166
167/// Partial [`IndexPolicy`] override; `None` fields are left unchanged.
168#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
169pub struct IndexPolicyUpdate {
170    /// New base interval, when set.
171    pub interval: Option<Duration>,
172    /// New duty-cycle multiplier, when set.
173    pub backoff: Option<u32>,
174    /// New pending-datom threshold, when set.
175    pub tail_threshold: Option<u64>,
176    /// New deferral deadline, when set.
177    pub tail_deadline: Option<Duration>,
178}
179
180impl IndexPolicy {
181    fn from_config(config: &NodeConfig) -> Self {
182        Self {
183            interval: config.index_interval,
184            backoff: config.index_backoff,
185            tail_threshold: config.index_tail_threshold,
186            tail_deadline: config.index_tail_deadline,
187        }
188    }
189
190    fn apply(&mut self, update: IndexPolicyUpdate) {
191        if let Some(interval) = update.interval {
192            self.interval = interval;
193        }
194        if let Some(backoff) = update.backoff {
195            self.backoff = backoff;
196        }
197        if let Some(tail_threshold) = update.tail_threshold {
198            self.tail_threshold = tail_threshold;
199        }
200        if let Some(tail_deadline) = update.tail_deadline {
201            self.tail_deadline = tail_deadline;
202        }
203    }
204
205    /// Decides whether pending work should publish now. `since_publish` is
206    /// the time since the last publication finished (or the job started),
207    /// `last_duration` how long it took (zero before the first), and
208    /// `pending` the recorded datoms appended since it — `None` until a
209    /// publication in this process establishes a baseline, which publishes
210    /// at base pacing (covers restarting with an unindexed backlog).
211    fn due(&self, since_publish: Duration, last_duration: Duration, pending: Option<u64>) -> bool {
212        let floor = self
213            .interval
214            .max(last_duration.saturating_mul(self.backoff));
215        if since_publish < floor {
216            return false;
217        }
218        match pending {
219            Some(pending) if pending < self.tail_threshold => since_publish >= self.tail_deadline,
220            _ => true,
221        }
222    }
223}
224
225/// Node operation failure.
226#[derive(Debug, Error)]
227pub enum NodeError {
228    /// Named database does not exist.
229    #[error("unknown database {0:?}")]
230    UnknownDb(String),
231    /// Database name is not storable.
232    #[error("invalid database name {0:?}")]
233    InvalidName(String),
234    /// Database root uses a storage format newer than this binary.
235    #[error("storage format {found} is newer than supported format {supported}")]
236    UnsupportedFormat {
237        /// Version found in the root.
238        found: u32,
239        /// Newest version understood by this binary.
240        supported: u32,
241    },
242    /// This node no longer holds the write lease.
243    #[error("deposed: write lease for {0:?} is held elsewhere")]
244    Deposed(String),
245    /// This node is a warm standby for the database; the lease holder
246    /// serves it.
247    #[error("standby for {db:?}: lease held by {owner} at {endpoint:?}")]
248    Standby {
249        /// Database name.
250        db: String,
251        /// Current lease owner id (empty when unknown).
252        owner: String,
253        /// Owner's advertised client endpoint (empty when unadvertised).
254        endpoint: String,
255    },
256    /// Payload failed to decode.
257    #[error(transparent)]
258    Codec(#[from] CodecError),
259    /// Transaction forms failed to convert.
260    #[error(transparent)]
261    TxForm(#[from] TxFormError),
262    /// Schema forms failed to convert.
263    #[error(transparent)]
264    SchemaForm(#[from] SchemaFormError),
265    /// Transaction pipeline failure.
266    #[error(transparent)]
267    Transact(#[from] TransactError),
268    /// Store failure.
269    #[error(transparent)]
270    Store(#[from] StoreError),
271    /// Log failure.
272    #[error(transparent)]
273    Log(#[from] LogError),
274    /// Lease failure.
275    #[error(transparent)]
276    Lease(#[from] LeaseError),
277    /// Malformed request.
278    #[error("bad request: {0}")]
279    BadRequest(String),
280}
281
282struct Naming {
283    schema: Schema,
284    idents: Idents,
285    interner: KeywordInterner,
286}
287
288/// Per-database state hosted by a node.
289pub struct DbState {
290    name: String,
291    transactor: EmbeddedTransactor,
292    log: Arc<dyn TransactionLog>,
293    naming: Mutex<Naming>,
294    commit: tokio::sync::Mutex<()>,
295    broadcast: broadcast::Sender<pb::subscribe_item::Item>,
296    basis: watch::Sender<u64>,
297    index_basis: AtomicU64,
298    index_policy: Mutex<IndexPolicy>,
299    held_lease: Mutex<Lease>,
300    deposed: AtomicBool,
301}
302
303impl DbState {
304    /// Database name.
305    #[must_use]
306    pub fn name(&self) -> &str {
307        &self.name
308    }
309
310    /// Current database value.
311    #[must_use]
312    pub fn db(&self) -> Db {
313        self.transactor.db()
314    }
315
316    /// Watch channel following the commit basis.
317    #[must_use]
318    pub fn basis_watch(&self) -> watch::Receiver<u64> {
319        self.basis.subscribe()
320    }
321
322    /// Subscribes to live stream items (reports, index announcements,
323    /// heartbeats).
324    #[must_use]
325    pub fn stream_items(&self) -> broadcast::Receiver<pb::subscribe_item::Item> {
326        self.broadcast.subscribe()
327    }
328
329    /// Basis of the newest published index root.
330    #[must_use]
331    pub fn index_basis(&self) -> u64 {
332        self.index_basis.load(Ordering::Acquire)
333    }
334
335    /// The indexing pacing policy currently in effect for this database.
336    #[must_use]
337    pub fn index_policy(&self) -> IndexPolicy {
338        *self
339            .index_policy
340            .lock()
341            .unwrap_or_else(std::sync::PoisonError::into_inner)
342    }
343
344    /// Currently held lease record.
345    #[must_use]
346    pub fn lease(&self) -> Lease {
347        self.held_lease
348            .lock()
349            .unwrap_or_else(std::sync::PoisonError::into_inner)
350            .clone()
351    }
352
353    /// Encoded schema/ident handshake payload plus a consistent basis and
354    /// interner snapshot for backfill encoding.
355    #[must_use]
356    pub fn handshake_snapshot(&self) -> (Vec<u8>, KeywordInterner) {
357        let naming = self
358            .naming
359            .lock()
360            .unwrap_or_else(std::sync::PoisonError::into_inner);
361        (
362            codec::encode_schema(&naming.schema, &naming.idents),
363            naming.interner.clone(),
364        )
365    }
366
367    /// Reads committed records in `[start, end)` from the durable log.
368    ///
369    /// # Errors
370    /// Returns an error when the log cannot be read.
371    pub async fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, NodeError> {
372        Ok(self.log.tx_range_async(start, end).await?)
373    }
374
375    /// Verifies this node still owns the write lease (identity check on
376    /// the root record; expiry changes from renewals do not matter).
377    async fn check_lease(&self, store: &dyn RootStore) -> Result<Lease, NodeError> {
378        if self.deposed.load(Ordering::Acquire) {
379            return Err(NodeError::Deposed(self.name.clone()));
380        }
381        let held = self.lease();
382        match lease::verify(store, &self.name, &held).await {
383            Ok(()) => Ok(held),
384            Err(LeaseError::Lost) => {
385                self.deposed.store(true, Ordering::Release);
386                Err(NodeError::Deposed(self.name.clone()))
387            }
388            Err(error) => Err(error.into()),
389        }
390    }
391}
392
393/// A running transactor node hosting every database under one data directory.
394pub struct TransactorNode {
395    config: NodeConfig,
396    store: Arc<NodeStore>,
397    log_backend: LogBackend,
398    dbs: std::sync::RwLock<HashMap<String, Arc<DbState>>>,
399    /// Databases this node is standing by for (HA mode): the lease is held
400    /// elsewhere and the standby poller attempts takeover on expiry.
401    standby: std::sync::RwLock<BTreeSet<String>>,
402    gc_lock: tokio::sync::Mutex<()>,
403    /// Serializes forks: two forks to the same target must not interleave
404    /// appends into one target log.
405    fork_lock: tokio::sync::Mutex<()>,
406    metrics: Metrics,
407    shutdown: watch::Sender<Option<String>>,
408}
409
410fn now_unix_ms() -> i64 {
411    i64::try_from(
412        SystemTime::now()
413            .duration_since(UNIX_EPOCH)
414            .unwrap_or_default()
415            .as_millis(),
416    )
417    .unwrap_or(i64::MAX)
418}
419
420fn valid_db_name(name: &str) -> bool {
421    !name.is_empty()
422        && name.len() <= 128
423        && name
424            .bytes()
425            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
426}
427
428impl TransactorNode {
429    /// Opens a node over `config.data_dir`, recovering every database found
430    /// there (acquiring its lease, waiting out held leases up to the
431    /// configured bound).
432    ///
433    /// # Errors
434    /// Returns an error when the store cannot be opened or a database cannot
435    /// be recovered.
436    pub async fn open(config: NodeConfig) -> Result<Arc<Self>, NodeError> {
437        let store = Arc::new(NodeStore::open(&config.store, &config.data_dir).await?);
438        let log_backend = LogBackend::for_spec(&config.store, &config.data_dir, Arc::clone(&store));
439        let node = Arc::new(Self {
440            config,
441            store,
442            log_backend,
443            dbs: std::sync::RwLock::new(HashMap::new()),
444            standby: std::sync::RwLock::new(BTreeSet::new()),
445            gc_lock: tokio::sync::Mutex::new(()),
446            fork_lock: tokio::sync::Mutex::new(()),
447            metrics: Metrics::default(),
448            shutdown: watch::channel(None).0,
449        });
450        let names: Vec<String> = node
451            .store
452            .list_roots("meta:")
453            .await?
454            .into_iter()
455            .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
456            .collect();
457        for name in names {
458            match node.open_db(&name).await {
459                Ok(state) => {
460                    node.dbs
461                        .write()
462                        .unwrap_or_else(std::sync::PoisonError::into_inner)
463                        .insert(name, state);
464                }
465                Err(NodeError::Lease(LeaseError::Held { owner, .. })) if node.config.ha => {
466                    tracing::info!(db = %name, %owner, "standing by; lease held elsewhere");
467                    node.standby
468                        .write()
469                        .unwrap_or_else(std::sync::PoisonError::into_inner)
470                        .insert(name);
471                }
472                Err(error) => return Err(error),
473            }
474        }
475        node.spawn_standby_poller();
476        node.spawn_scheduled_gc();
477        Ok(node)
478    }
479
480    /// The node's storage-service backend (blobs + roots).
481    #[must_use]
482    pub fn store(&self) -> &Arc<NodeStore> {
483        &self.store
484    }
485
486    /// Node configuration.
487    #[must_use]
488    pub fn config(&self) -> &NodeConfig {
489        &self.config
490    }
491
492    /// Process observability counters.
493    #[must_use]
494    pub const fn metrics(&self) -> &Metrics {
495        &self.metrics
496    }
497
498    fn spawn_scheduled_gc(self: &Arc<Self>) {
499        let Some(interval) = self.config.gc_interval else {
500            return;
501        };
502        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
503            // Embedded callers may construct an empty catalog before they
504            // enter a runtime. Process wiring opens nodes inside Tokio.
505            return;
506        };
507        let node = Arc::clone(self);
508        runtime.spawn(async move {
509            let mut ticker = tokio::time::interval(interval);
510            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
511            // `interval` ticks immediately; scheduled duties should wait a full interval.
512            ticker.tick().await;
513            loop {
514                ticker.tick().await;
515                if let Err(error) = node.gc_deleted().await {
516                    tracing::warn!(%error, "scheduled garbage collection failed");
517                }
518            }
519        });
520    }
521
522    /// Watch channel that reports a shutdown reason when the node deposes.
523    #[must_use]
524    pub fn shutdown_watch(&self) -> watch::Receiver<Option<String>> {
525        self.shutdown.subscribe()
526    }
527
528    /// Deposes a hosted database. In HA mode the database returns to
529    /// standby (the poller re-attempts takeover); otherwise the whole
530    /// process shuts down and a supervisor restart re-acquires or waits.
531    fn depose(&self, state: &DbState, reason: &str) {
532        state.deposed.store(true, Ordering::Release);
533        if self.config.ha {
534            tracing::warn!(db = %state.name, reason, "deposed; returning to standby");
535            self.dbs
536                .write()
537                .unwrap_or_else(std::sync::PoisonError::into_inner)
538                .remove(&state.name);
539            self.standby
540                .write()
541                .unwrap_or_else(std::sync::PoisonError::into_inner)
542                .insert(state.name.clone());
543        } else {
544            let _ = self
545                .shutdown
546                .send(Some(format!("database {:?}: {reason}", state.name)));
547        }
548    }
549
550    fn advertised(&self) -> &str {
551        self.config.advertise.as_deref().unwrap_or("")
552    }
553
554    /// Acquires the lease for `name`. In HA mode a held lease surfaces
555    /// immediately (the caller stands by); otherwise startup waits it out
556    /// up to the configured bound.
557    async fn acquire_lease(&self, name: &str) -> Result<Lease, NodeError> {
558        let deadline = now_unix_ms() + self.config.lease_wait_ms;
559        loop {
560            match lease::acquire(
561                self.store.as_ref(),
562                name,
563                &self.config.owner,
564                self.advertised(),
565                self.config.lease_ttl_ms,
566                now_unix_ms(),
567            )
568            .await
569            {
570                Ok(held) => return Ok(held),
571                Err(LeaseError::Held { .. }) if !self.config.ha && now_unix_ms() < deadline => {
572                    tokio::time::sleep(Duration::from_millis(200)).await;
573                }
574                Err(error) => return Err(error.into()),
575            }
576        }
577    }
578
579    async fn open_db(self: &Arc<Self>, name: &str) -> Result<Arc<DbState>, NodeError> {
580        let meta = self
581            .store
582            .get_root(&meta_root_name(name))
583            .await?
584            .ok_or_else(|| NodeError::UnknownDb(name.to_owned()))?;
585        let (schema, idents, interner) = codec::decode_metadata(&meta)?;
586        let root_name = db_root_name(name);
587        let current = self
588            .store
589            .get_root(&root_name)
590            .await?
591            .as_deref()
592            .and_then(DbRoot::decode);
593        if let Some(root) = &current
594            && root.format_version > corium_store::FORMAT_VERSION
595        {
596            return Err(NodeError::UnsupportedFormat {
597                found: root.format_version,
598                supported: corium_store::FORMAT_VERSION,
599            });
600        }
601        // Acquisition rewrites the root record under our lease version, so
602        // it doubles as the fence bump: a deposed writer's pending root CAS
603        // now has stale expected bytes and must fail. It also preserves the
604        // published snapshot's recovery hints, so the root we re-read below
605        // carries everything index-root recovery needs.
606        let held = self.acquire_lease(name).await?;
607        // The log tail replay below happens strictly after the fence, so it
608        // observes every record a previous owner could ever have acked.
609        let log = self.log_backend.open(name, held.version).await?;
610        let post_fence = self
611            .store
612            .get_root(&root_name)
613            .await?
614            .as_deref()
615            .and_then(DbRoot::decode);
616        let transactor = self
617            .recover_transactor(name, &schema, &idents, &interner, post_fence.as_ref(), &log)
618            .await?;
619        let basis_t = transactor.db().basis_t();
620        let index_basis = post_fence.map_or(0, |root| root.index_basis_t);
621        let state = Arc::new(DbState {
622            name: name.to_owned(),
623            transactor,
624            log,
625            naming: Mutex::new(Naming {
626                schema,
627                idents,
628                interner,
629            }),
630            commit: tokio::sync::Mutex::new(()),
631            broadcast: broadcast::channel(1024).0,
632            basis: watch::channel(basis_t).0,
633            index_basis: AtomicU64::new(index_basis),
634            index_policy: Mutex::new(IndexPolicy::from_config(&self.config)),
635            held_lease: Mutex::new(held),
636            deposed: AtomicBool::new(false),
637        });
638        self.spawn_maintenance(&state);
639        Ok(state)
640    }
641
642    /// Builds the recovered transactor for `open_db`.
643    ///
644    /// When the post-fence root publishes a current snapshot with recovery
645    /// hints, recovers from the index root plus the log tail — open time
646    /// proportional to the tail, not the whole history. Any missing hint
647    /// (a pre-recovery root, or a bare fence bump with no snapshot) or a
648    /// failure materializing the snapshot falls back to full-log replay,
649    /// which is always correct because the log is the source of truth.
650    async fn recover_transactor(
651        &self,
652        name: &str,
653        schema: &Schema,
654        idents: &Idents,
655        interner: &KeywordInterner,
656        root: Option<&DbRoot>,
657        log: &Arc<dyn TransactionLog>,
658    ) -> Result<EmbeddedTransactor, NodeError> {
659        // `next_entity_id == 0` is the "no hint" sentinel (see DbRoot); it and
660        // an absent snapshot both rule out the tail-only path.
661        if let Some(root) = root
662            && let Some(roots) = &root.roots
663            && root.next_entity_id != 0
664        {
665            match self
666                .load_current_snapshot(
667                    root,
668                    &roots[IndexOrder::Eavt as usize],
669                    schema,
670                    idents,
671                    interner,
672                )
673                .await
674            {
675                Ok(snapshot) => {
676                    return Ok(EmbeddedTransactor::recover_from_snapshot_async(
677                        snapshot,
678                        root.next_entity_id,
679                        root.last_tx_instant,
680                        Arc::clone(log),
681                    )
682                    .await?);
683                }
684                Err(error) => {
685                    tracing::warn!(
686                        db = %name,
687                        %error,
688                        "index-root recovery failed; falling back to full-log replay"
689                    );
690                }
691            }
692        }
693        let base = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
694        Ok(EmbeddedTransactor::recover_from_async(base, Arc::clone(log)).await?)
695    }
696
697    /// Materializes the current database value at a published index root from
698    /// its EAVT snapshot — the transactor-side counterpart of the peer's
699    /// bootstrap (`corium-peer`'s `load_current_snapshot`). Only current
700    /// facts are reconstructed; the log tail carries everything since.
701    async fn load_current_snapshot(
702        &self,
703        root: &DbRoot,
704        eavt: &BlobId,
705        schema: &Schema,
706        idents: &Idents,
707        interner: &KeywordInterner,
708    ) -> Result<Db, StoreError> {
709        let datoms = self
710            .load_index_keys(eavt)
711            .await?
712            .into_iter()
713            .map(|key| Datom::from_key(IndexOrder::Eavt, &key))
714            .collect::<Result<Vec<_>, _>>()
715            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
716        Ok(Db::from_current_snapshot(
717            root.index_basis_t,
718            schema.clone(),
719            idents.clone(),
720            interner.clone(),
721            datoms,
722        ))
723    }
724
725    /// Reads one covering index's sorted key stream from the blob store: a
726    /// format-3 manifest's chunks in order, or a pre-format-3 flat blob.
727    async fn load_index_keys(&self, id: &BlobId) -> Result<Vec<Vec<u8>>, StoreError> {
728        let blob = self
729            .store
730            .get(id)
731            .await?
732            .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
733        if !is_index_manifest(&blob) {
734            return decode_segment_keys(&blob);
735        }
736        let mut keys = Vec::new();
737        for child in decode_index_manifest(&blob)? {
738            let chunk = self
739                .store
740                .get(&child)
741                .await?
742                .ok_or_else(|| StoreError::MissingBlob(child.clone()))?;
743            keys.extend(decode_segment_keys(&chunk)?);
744        }
745        Ok(keys)
746    }
747
748    /// HA standby duty: at the lease-renewal cadence, rediscover databases
749    /// (including ones created on the active after this process started)
750    /// and attempt takeover of any whose lease has lapsed. Takeover is
751    /// ordinary startup — acquire (which fences), replay the log tail,
752    /// serve — per the crash-only design.
753    fn spawn_standby_poller(self: &Arc<Self>) {
754        if !self.config.ha {
755            return;
756        }
757        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
758            return;
759        };
760        let ttl = self.config.lease_ttl_ms;
761        let poll_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
762        let node = Arc::clone(self);
763        runtime.spawn(async move {
764            let mut ticker = tokio::time::interval(poll_every);
765            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
766            loop {
767                ticker.tick().await;
768                if let Err(error) = node.standby_scan().await {
769                    tracing::warn!(%error, "standby scan failed");
770                }
771            }
772        });
773    }
774
775    /// One standby pass: refresh the standby set from the catalog and try
776    /// to take over lapsed leases.
777    async fn standby_scan(self: &Arc<Self>) -> Result<(), NodeError> {
778        let names: Vec<String> = self
779            .store
780            .list_roots("meta:")
781            .await?
782            .into_iter()
783            .filter_map(|root| root.strip_prefix("meta:").map(str::to_owned))
784            .collect();
785        {
786            let mut standby = self
787                .standby
788                .write()
789                .unwrap_or_else(std::sync::PoisonError::into_inner);
790            standby.retain(|name| names.contains(name));
791        }
792        for name in names {
793            if self
794                .dbs
795                .read()
796                .unwrap_or_else(std::sync::PoisonError::into_inner)
797                .contains_key(&name)
798            {
799                continue;
800            }
801            match self.open_db(&name).await {
802                Ok(state) => {
803                    tracing::info!(db = %name, owner = %self.config.owner, "standby took over write lease");
804                    self.standby
805                        .write()
806                        .unwrap_or_else(std::sync::PoisonError::into_inner)
807                        .remove(&name);
808                    self.dbs
809                        .write()
810                        .unwrap_or_else(std::sync::PoisonError::into_inner)
811                        .insert(name, state);
812                }
813                Err(NodeError::Lease(LeaseError::Held { .. })) => {
814                    self.standby
815                        .write()
816                        .unwrap_or_else(std::sync::PoisonError::into_inner)
817                        .insert(name);
818                }
819                Err(error) => {
820                    tracing::warn!(db = %name, %error, "standby takeover attempt failed");
821                }
822            }
823        }
824        Ok(())
825    }
826
827    fn spawn_maintenance(self: &Arc<Self>, state: &Arc<DbState>) {
828        let ttl = self.config.lease_ttl_ms;
829        let renew_every = Duration::from_millis(u64::try_from(ttl / 3).unwrap_or(1).max(50));
830        // Lease renewal.
831        let node = Arc::clone(self);
832        let db = Arc::clone(state);
833        tokio::spawn(async move {
834            let mut ticker = tokio::time::interval(renew_every);
835            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
836            loop {
837                ticker.tick().await;
838                if db.deposed.load(Ordering::Acquire) {
839                    return;
840                }
841                // Serialize the root update and local held-lease update with
842                // transaction lease checks so they cannot observe different
843                // renewal generations and falsely depose this node.
844                let _commit = db.commit.lock().await;
845                let held = db.lease();
846                let name = db.name.clone();
847                let renewed =
848                    lease::renew(node.store.as_ref(), &name, &held, ttl, now_unix_ms()).await;
849                match renewed {
850                    Ok(renewed) => {
851                        *db.held_lease
852                            .lock()
853                            .unwrap_or_else(std::sync::PoisonError::into_inner) = renewed;
854                    }
855                    Err(LeaseError::Lost) => {
856                        node.depose(&db, "write lease lost");
857                        return;
858                    }
859                    Err(_) => {}
860                }
861            }
862        });
863        self.spawn_indexing(state);
864        // Heartbeats.
865        let node = Arc::clone(self);
866        let db = Arc::clone(state);
867        tokio::spawn(async move {
868            let mut ticker = tokio::time::interval(node.config.heartbeat_interval);
869            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
870            loop {
871                ticker.tick().await;
872                if db.deposed.load(Ordering::Acquire) {
873                    return;
874                }
875                let _ = db
876                    .broadcast
877                    .send(pb::subscribe_item::Item::Heartbeat(pb::Heartbeat {
878                        basis_t: db.db().basis_t(),
879                    }));
880            }
881        });
882    }
883
884    /// Spawns the background indexing job, paced by the database's
885    /// [`IndexPolicy`] (re-read every iteration so runtime overrides apply
886    /// within a poll interval).
887    fn spawn_indexing(self: &Arc<Self>, state: &Arc<DbState>) {
888        // How often the job re-checks work and policy when the configured
889        // interval is longer; runtime policy changes and pending work are
890        // never noticed later than this.
891        const POLICY_POLL: Duration = Duration::from_secs(1);
892        let node = Arc::clone(self);
893        let db = Arc::clone(state);
894        tokio::spawn(async move {
895            let mut published_at = Instant::now();
896            let mut last_duration = Duration::ZERO;
897            let mut published_len: Option<u64> = None;
898            loop {
899                let policy = db.index_policy();
900                tokio::time::sleep(policy.interval.min(POLICY_POLL)).await;
901                if db.deposed.load(Ordering::Acquire) {
902                    return;
903                }
904                let snapshot = db.db();
905                if snapshot.basis_t() <= db.index_basis() {
906                    continue;
907                }
908                let recorded_len = u64::try_from(snapshot.recorded_len()).unwrap_or(u64::MAX);
909                let pending = published_len.map(|len| recorded_len.saturating_sub(len));
910                if !policy.due(published_at.elapsed(), last_duration, pending) {
911                    continue;
912                }
913                match node.publish_db_indexes(&db).await {
914                    Ok((_, duration)) => {
915                        last_duration = duration;
916                        // publish_db_indexes snapshots after this loop did,
917                        // so the covered length is at least recorded_len; the
918                        // underestimate only makes the next tail look bigger.
919                        published_len = Some(recorded_len);
920                    }
921                    Err(NodeError::Deposed(_)) => return,
922                    Err(_) => {}
923                }
924                published_at = Instant::now();
925            }
926        });
927    }
928
929    /// Publishes `db`'s covering indexes now, returning the published index
930    /// basis and how long the publication took. Serialized with garbage
931    /// collection; deposes the database when the root is fenced by a newer
932    /// lease.
933    async fn publish_db_indexes(&self, db: &Arc<DbState>) -> Result<(u64, Duration), NodeError> {
934        let _gc = self.gc_lock.lock().await;
935        let version = db.lease().version;
936        let root_name = db_root_name(&db.name);
937        let started = Instant::now();
938        let published = db
939            .transactor
940            .publish_indexes(self.store.as_ref(), &root_name, version)
941            .await;
942        let duration = started.elapsed();
943        self.metrics.record_index(duration);
944        match published {
945            Ok(root) => {
946                tracing::debug!(db = %db.name, index_basis_t = root.index_basis_t, "published indexes");
947                db.index_basis.store(root.index_basis_t, Ordering::Release);
948                let _ = db
949                    .broadcast
950                    .send(pb::subscribe_item::Item::IndexBasis(pb::IndexBasis {
951                        index_basis_t: root.index_basis_t,
952                    }));
953                Ok((root.index_basis_t, duration))
954            }
955            Err(TransactError::Deposed { .. }) => {
956                self.depose(db, "database root fenced by a newer lease");
957                Err(NodeError::Deposed(db.name.clone()))
958            }
959            Err(error) => Err(error.into()),
960        }
961    }
962
963    /// Publishes indexes for `name` immediately, bypassing the pacing
964    /// policy (the catalog `RequestIndex` RPC). Returns the resulting index
965    /// basis; when the published indexes already cover every committed
966    /// transaction, returns the current index basis without publishing.
967    ///
968    /// # Errors
969    /// Returns [`NodeError`] when the database is unknown, this node is
970    /// deposed or standing by, or publication fails.
971    pub async fn request_index(&self, name: &str) -> Result<u64, NodeError> {
972        let state = self.db_state(name).await?;
973        if state.db().basis_t() <= state.index_basis() {
974            return Ok(state.index_basis());
975        }
976        self.publish_db_indexes(&state)
977            .await
978            .map(|(index_basis_t, _)| index_basis_t)
979    }
980
981    /// Applies per-database indexing-policy overrides at runtime, returning
982    /// the policy now in effect. An empty update reads the current policy.
983    ///
984    /// # Errors
985    /// Returns [`NodeError`] when the database is unknown or served
986    /// elsewhere.
987    pub async fn set_index_policy(
988        &self,
989        name: &str,
990        update: IndexPolicyUpdate,
991    ) -> Result<IndexPolicy, NodeError> {
992        let state = self.db_state(name).await?;
993        let mut policy = state
994            .index_policy
995            .lock()
996            .unwrap_or_else(std::sync::PoisonError::into_inner);
997        policy.apply(update);
998        Ok(*policy)
999    }
1000
1001    /// Looks up a hosted database.
1002    ///
1003    /// # Errors
1004    /// Returns [`NodeError::Standby`] when this HA node is standing by for
1005    /// the database, [`NodeError::UnknownDb`] when absent.
1006    pub async fn db_state(&self, name: &str) -> Result<Arc<DbState>, NodeError> {
1007        if let Some(state) = self
1008            .dbs
1009            .read()
1010            .unwrap_or_else(std::sync::PoisonError::into_inner)
1011            .get(name)
1012            .cloned()
1013        {
1014            return Ok(state);
1015        }
1016        if self.config.ha
1017            && self
1018                .standby
1019                .read()
1020                .unwrap_or_else(std::sync::PoisonError::into_inner)
1021                .contains(name)
1022        {
1023            let root = self
1024                .store
1025                .get_root(&db_root_name(name))
1026                .await?
1027                .as_deref()
1028                .and_then(DbRoot::decode);
1029            return Err(NodeError::Standby {
1030                db: name.to_owned(),
1031                owner: root.as_ref().map(|r| r.owner.clone()).unwrap_or_default(),
1032                endpoint: root.map(|r| r.owner_endpoint).unwrap_or_default(),
1033            });
1034        }
1035        Err(NodeError::UnknownDb(name.to_owned()))
1036    }
1037
1038    /// Databases this node currently stands by for (HA mode).
1039    #[must_use]
1040    pub fn standby_dbs(&self) -> Vec<String> {
1041        self.standby
1042            .read()
1043            .unwrap_or_else(std::sync::PoisonError::into_inner)
1044            .iter()
1045            .cloned()
1046            .collect()
1047    }
1048
1049    /// Creates a database with the supplied EDN schema forms; returns
1050    /// `false` when it already exists.
1051    ///
1052    /// # Errors
1053    /// Returns an error for invalid names/schema or store failures.
1054    pub async fn create_db(
1055        self: &Arc<Self>,
1056        name: &str,
1057        schema_edn: &[u8],
1058    ) -> Result<bool, NodeError> {
1059        if !valid_db_name(name) {
1060            return Err(NodeError::InvalidName(name.to_owned()));
1061        }
1062        if self
1063            .dbs
1064            .read()
1065            .unwrap_or_else(std::sync::PoisonError::into_inner)
1066            .contains_key(name)
1067        {
1068            return Ok(false);
1069        }
1070        let forms = match codec::decode_edn(schema_edn)? {
1071            Edn::Vector(items) | Edn::List(items) => items,
1072            Edn::Nil => Vec::new(),
1073            other => {
1074                return Err(NodeError::BadRequest(format!(
1075                    "schema must be a vector of attribute maps, got {other}"
1076                )));
1077            }
1078        };
1079        let (schema, idents) = schema_from_edn(&forms)?;
1080        let meta = codec::encode_metadata(&schema, &idents, &KeywordInterner::default());
1081        match self
1082            .store
1083            .cas_root(&meta_root_name(name), None, &meta)
1084            .await
1085        {
1086            Ok(()) => {}
1087            Err(StoreError::CasFailed { .. }) => return Ok(false),
1088            Err(error) => return Err(error.into()),
1089        }
1090        let state = self.open_db(name).await?;
1091        self.dbs
1092            .write()
1093            .unwrap_or_else(std::sync::PoisonError::into_inner)
1094            .insert(name.to_owned(), state);
1095        Ok(true)
1096    }
1097
1098    /// Forks `source` into a new database `target` whose state duplicates
1099    /// the source as of transaction `as_of_t` (`0` forks at the current
1100    /// basis). Only the log prefix is copied; the target replays it and
1101    /// publishes its own indexes, while blob segments dedupe by content
1102    /// address. Returns the fork's basis, or `None` when `target` already
1103    /// exists.
1104    ///
1105    /// # Errors
1106    /// Returns an error for an invalid target name, an unknown source, an
1107    /// `as_of_t` ahead of the source's basis, or store/log failures.
1108    pub async fn fork_db(
1109        self: &Arc<Self>,
1110        source: &str,
1111        target: &str,
1112        as_of_t: u64,
1113    ) -> Result<Option<u64>, NodeError> {
1114        if !valid_db_name(target) {
1115            return Err(NodeError::InvalidName(target.to_owned()));
1116        }
1117        if source == target {
1118            return Err(NodeError::BadRequest(
1119                "fork target must differ from the source".into(),
1120            ));
1121        }
1122        let state = self.db_state(source).await?;
1123        let basis = state.db().basis_t();
1124        let t = if as_of_t == 0 { basis } else { as_of_t };
1125        if t > basis {
1126            return Err(NodeError::BadRequest(format!(
1127                "as-of t {t} is ahead of {source:?} basis {basis}"
1128            )));
1129        }
1130        let _guard = self.fork_lock.lock().await;
1131        if self
1132            .dbs
1133            .read()
1134            .unwrap_or_else(std::sync::PoisonError::into_inner)
1135            .contains_key(target)
1136            || self
1137                .store
1138                .get_root(&meta_root_name(target))
1139                .await?
1140                .is_some()
1141            || self.log_backend.exists(target).await
1142        {
1143            return Ok(None);
1144        }
1145        // Capture the records before the metadata: meta is made durable
1146        // before any record that references it, so a meta read afterwards is
1147        // always a sufficient decode dictionary for the captured prefix.
1148        // Transaction numbers are contiguous from 1, so the prefix through
1149        // `t` is exactly the source's state at that basis.
1150        let records = state.log.tx_range_async(0, Some(t + 1)).await?;
1151        let meta = self
1152            .store
1153            .get_root(&meta_root_name(source))
1154            .await?
1155            .ok_or_else(|| NodeError::UnknownDb(source.to_owned()))?;
1156        // Write the log under version 0 so it sorts beneath the
1157        // lease-versioned file the target's first open creates, and publish
1158        // meta last — it is the catalog entry, so a crash mid-fork never
1159        // catalogs a target without its log.
1160        let log = self.log_backend.open(target, 0).await?;
1161        for record in &records {
1162            log.append_async(record).await?;
1163        }
1164        drop(log);
1165        match self
1166            .store
1167            .cas_root(&meta_root_name(target), None, &meta)
1168            .await
1169        {
1170            Ok(()) => {}
1171            Err(StoreError::CasFailed { .. }) => {
1172                // Another node claimed the name first; discard our log copy.
1173                self.log_backend.delete_all(target).await?;
1174                return Ok(None);
1175            }
1176            Err(error) => return Err(error.into()),
1177        }
1178        let state = self.open_db(target).await?;
1179        self.dbs
1180            .write()
1181            .unwrap_or_else(std::sync::PoisonError::into_inner)
1182            .insert(target.to_owned(), state);
1183        Ok(Some(t))
1184    }
1185
1186    /// Deletes a database: unhosts it, releases its lease, and removes its
1187    /// roots and log. Blobs remain until [`Self::gc_deleted`].
1188    ///
1189    /// # Errors
1190    /// Returns an error when roots or the log cannot be removed.
1191    pub async fn delete_db(&self, name: &str) -> Result<bool, NodeError> {
1192        let Some(state) = self
1193            .dbs
1194            .write()
1195            .unwrap_or_else(std::sync::PoisonError::into_inner)
1196            .remove(name)
1197        else {
1198            return Ok(false);
1199        };
1200        state.deposed.store(true, Ordering::Release);
1201        self.standby
1202            .write()
1203            .unwrap_or_else(std::sync::PoisonError::into_inner)
1204            .remove(name);
1205        self.store.delete_root(&db_root_name(name)).await?;
1206        self.store.delete_root(&meta_root_name(name)).await?;
1207        self.log_backend.delete_all(name).await?;
1208        Ok(true)
1209    }
1210
1211    /// Lists hosted databases.
1212    #[must_use]
1213    pub fn list_dbs(&self) -> Vec<String> {
1214        let mut names: Vec<String> = self
1215            .dbs
1216            .read()
1217            .unwrap_or_else(std::sync::PoisonError::into_inner)
1218            .keys()
1219            .cloned()
1220            .collect();
1221        names.sort();
1222        names
1223    }
1224
1225    /// Sweeps blobs unreachable from any live database root (including
1226    /// everything left behind by deleted databases and superseded indexes).
1227    ///
1228    /// # Errors
1229    /// Returns an error when the store cannot be enumerated or swept.
1230    pub async fn gc_deleted(&self) -> Result<u64, NodeError> {
1231        self.gc_deleted_with_retention(self.config.gc_retention)
1232            .await
1233    }
1234
1235    /// Sweeps unreachable blobs older than the caller-supplied retention.
1236    ///
1237    /// # Errors
1238    /// Returns an error when the store cannot be enumerated or swept.
1239    pub async fn gc_deleted_with_retention(&self, retention: Duration) -> Result<u64, NodeError> {
1240        let _gc = self.gc_lock.lock().await;
1241        let mut live = Vec::new();
1242        for root_name in self.store.list_roots("db:").await? {
1243            if let Some(root) = self
1244                .store
1245                .get_root(&root_name)
1246                .await?
1247                .as_deref()
1248                .and_then(DbRoot::decode)
1249                && let Some(roots) = root.roots
1250            {
1251                live.extend(roots);
1252            }
1253        }
1254        let report = mark_and_sweep_retained(
1255            self.store.as_ref(),
1256            live,
1257            |_, bytes| corium_store::index_blob_children(bytes),
1258            retention,
1259            SystemTime::now(),
1260        )
1261        .await?;
1262        self.metrics
1263            .record_gc(report.swept as u64, report.retained as u64);
1264        tracing::info!(
1265            marked = report.marked,
1266            swept = report.swept,
1267            retained = report.retained,
1268            "garbage collection completed"
1269        );
1270        Ok(report.swept as u64)
1271    }
1272
1273    /// Validates, appends, applies, and reports one transaction supplied as
1274    /// composite-encoded EDN transaction forms.
1275    ///
1276    /// # Errors
1277    /// Returns [`NodeError`] for decode/validation failures, lease loss, or
1278    /// storage failures.
1279    pub async fn transact(
1280        &self,
1281        name: &str,
1282        tx_data: &[u8],
1283    ) -> Result<pb::TransactResponse, NodeError> {
1284        let started = Instant::now();
1285        let result = self
1286            .transact_inner(name, tx_data)
1287            .instrument(tracing::info_span!("transact", db = name))
1288            .await;
1289        self.metrics.record_tx(started.elapsed(), result.is_ok());
1290        if let Err(error) = &result {
1291            tracing::warn!(%error, "transaction failed");
1292        }
1293        result
1294    }
1295
1296    async fn transact_inner(
1297        &self,
1298        name: &str,
1299        tx_data: &[u8],
1300    ) -> Result<pb::TransactResponse, NodeError> {
1301        let state = self.db_state(name).await?;
1302        let decoded = codec::decode_edn(tx_data)?;
1303        let forms = decoded
1304            .as_seq()
1305            .ok_or_else(|| NodeError::BadRequest("tx-data must be a vector".into()))?
1306            .to_vec();
1307        let queued = self.metrics.queue_waiter();
1308        let commit = state.commit.lock().await;
1309        drop(queued);
1310        let _commit = commit;
1311        state.check_lease(self.store.as_ref()).await?;
1312        // Expand user database-function invocations against the
1313        // db-in-transaction (the value under the commit lock) before native
1314        // conversion. The expander blocks up to its budget deadline, so it
1315        // runs off the async workers.
1316        let forms = if let Some(expander) = &self.config.tx_fn_expander {
1317            let expander = Arc::clone(expander);
1318            let db = state.transactor.db();
1319            tokio::task::spawn_blocking(move || expander.expand(&db, forms))
1320                .await
1321                .map_err(|error| NodeError::BadRequest(format!("expander task failed: {error}")))?
1322                .map_err(NodeError::BadRequest)?
1323        } else {
1324            forms
1325        };
1326        // Convert forms, interning any new keyword values.
1327        let (items, naming_changed, idents, interner, schema) = {
1328            let mut naming = state
1329                .naming
1330                .lock()
1331                .unwrap_or_else(std::sync::PoisonError::into_inner);
1332            let db = state.transactor.db();
1333            let before = naming.interner.len();
1334            let items = tx_items_from_edn(&db, &mut naming.interner, &forms)?;
1335            (
1336                items,
1337                naming.interner.len() > before,
1338                naming.idents.clone(),
1339                naming.interner.clone(),
1340                naming.schema.clone(),
1341            )
1342        };
1343        if naming_changed {
1344            // New keyword names must be durable before the datoms that
1345            // reference them; recovery decodes the log against this meta.
1346            let meta = codec::encode_metadata(&schema, &idents, &interner);
1347            loop {
1348                let current = self.store.get_root(&meta_root_name(name)).await?;
1349                match self
1350                    .store
1351                    .cas_root(&meta_root_name(name), current.as_deref(), &meta)
1352                    .await
1353                {
1354                    Ok(()) => break,
1355                    Err(StoreError::CasFailed { .. }) => {}
1356                    Err(error) => return Err(error.into()),
1357                }
1358            }
1359            state.transactor.update_naming(idents, interner.clone());
1360        }
1361        let report = state.transactor.transact_async(items).await?;
1362        // Post-append fence: acknowledge only if ownership was intact after
1363        // the record became durable. A takeover that raced the append will
1364        // have replayed the log *after* rewriting the root record, so a
1365        // record we acked here is provably in the successor's replay, and a
1366        // record we refuse here lands in our version's log file where the
1367        // successor's cutoff discards it (see log-and-transactor.md).
1368        if let Err(error) = state.check_lease(self.store.as_ref()).await {
1369            if matches!(error, NodeError::Deposed(_)) {
1370                self.depose(&state, "write lease lost after durable append");
1371            }
1372            // Either way the transaction is not acknowledged; a transient
1373            // store failure here is ambiguous to the caller, exactly like a
1374            // crash between append and reply.
1375            return Err(error);
1376        }
1377        let t = report.db_after.basis_t();
1378        let datoms = codec::encode_datoms(&report.tx.datoms, &interner)?;
1379        let tempids = codec::encode_edn(&Edn::Map(
1380            report
1381                .tx
1382                .tempids
1383                .iter()
1384                .map(|(tempid, eid)| {
1385                    (
1386                        Edn::Str(tempid.clone()),
1387                        Edn::Long(i64::try_from(eid.raw()).unwrap_or(i64::MAX)),
1388                    )
1389                })
1390                .collect(),
1391        ));
1392        let _ = state
1393            .broadcast
1394            .send(pb::subscribe_item::Item::Report(pb::TxReport {
1395                t,
1396                tx_instant: report.tx_instant,
1397                datoms: datoms.clone(),
1398            }));
1399        let _ = state.basis.send(t);
1400        Ok(pb::TransactResponse {
1401            basis_before: report.db_before.basis_t(),
1402            basis_t: t,
1403            tx_instant: report.tx_instant,
1404            tempids,
1405            tx_data: datoms,
1406        })
1407    }
1408
1409    /// Current status for a database.
1410    ///
1411    /// # Errors
1412    /// Returns [`NodeError::UnknownDb`] when absent.
1413    pub async fn status(&self, name: &str) -> Result<pb::StatusResponse, NodeError> {
1414        let state = self.db_state(name).await?;
1415        let db = state.db();
1416        let counts = db.stats();
1417        let held = state.lease();
1418        let metrics = self.metrics.snapshot();
1419        Ok(pb::StatusResponse {
1420            basis_t: db.basis_t(),
1421            index_basis_t: state.index_basis(),
1422            lease_owner: held.owner,
1423            lease_version: held.version,
1424            lease_expires_unix_ms: held.expires_unix_ms,
1425            datom_count: counts.datoms as u64,
1426            entity_count: counts.entities as u64,
1427            attribute_count: counts.attributes as u64,
1428            transaction_count: metrics.tx_total,
1429            transaction_failure_count: metrics.tx_failed,
1430            transaction_queue_depth: metrics.queue_depth,
1431            index_lag: db.basis_t().saturating_sub(state.index_basis()),
1432            indexing_runs: metrics.index_runs,
1433            gc_runs: metrics.gc_runs,
1434            gc_swept_blobs: metrics.gc_swept,
1435            lease_owner_endpoint: held.endpoint,
1436        })
1437    }
1438
1439    /// Releases every held write lease (graceful shutdown): the record is
1440    /// expired in place so a standby's next poll takes over immediately
1441    /// instead of waiting out the TTL. Hosted databases stop accepting
1442    /// work first, so nothing commits after its lease is gone.
1443    pub async fn release_leases(&self) {
1444        let states: Vec<Arc<DbState>> = self
1445            .dbs
1446            .write()
1447            .unwrap_or_else(std::sync::PoisonError::into_inner)
1448            .drain()
1449            .map(|(_, state)| state)
1450            .collect();
1451        for state in states {
1452            state.deposed.store(true, Ordering::Release);
1453            if let Err(error) =
1454                lease::release(self.store.as_ref(), &state.name, &state.lease()).await
1455            {
1456                tracing::warn!(db = %state.name, %error, "lease release failed at shutdown");
1457            }
1458        }
1459    }
1460
1461    /// Waits until the database basis reaches `t`, returning the basis seen.
1462    ///
1463    /// # Errors
1464    /// Returns [`NodeError::UnknownDb`] when absent.
1465    pub async fn sync(&self, name: &str, t: u64) -> Result<u64, NodeError> {
1466        let state = self.db_state(name).await?;
1467        let mut basis = state.basis_watch();
1468        let target = if t == 0 { *basis.borrow() } else { t };
1469        loop {
1470            let current = *basis.borrow();
1471            if current >= target {
1472                return Ok(current);
1473            }
1474            if basis.changed().await.is_err() {
1475                return Ok(*basis.borrow());
1476            }
1477        }
1478    }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483    use super::IndexPolicy;
1484    use std::time::Duration;
1485
1486    fn pacing(interval_ms: u64, backoff: u32, threshold: u64, deadline_ms: u64) -> IndexPolicy {
1487        IndexPolicy {
1488            interval: Duration::from_millis(interval_ms),
1489            backoff,
1490            tail_threshold: threshold,
1491            tail_deadline: Duration::from_millis(deadline_ms),
1492        }
1493    }
1494
1495    #[test]
1496    fn base_interval_gates_publication() {
1497        let pacing = pacing(100, 4, 0, 60_000);
1498        assert!(!pacing.due(Duration::from_millis(99), Duration::ZERO, None));
1499        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1500    }
1501
1502    #[test]
1503    fn backoff_stretches_the_floor_past_the_interval() {
1504        let pacing = pacing(100, 4, 0, 60_000);
1505        let last = Duration::from_millis(300);
1506        assert!(!pacing.due(Duration::from_millis(1_199), last, Some(10)));
1507        assert!(pacing.due(Duration::from_millis(1_200), last, Some(10)));
1508    }
1509
1510    #[test]
1511    fn zero_backoff_keeps_the_base_interval() {
1512        let pacing = pacing(100, 0, 0, 60_000);
1513        assert!(pacing.due(
1514            Duration::from_millis(100),
1515            Duration::from_secs(30),
1516            Some(10)
1517        ));
1518    }
1519
1520    #[test]
1521    fn fast_publications_leave_the_interval_untouched() {
1522        let pacing = pacing(5_000, 4, 0, 60_000);
1523        assert!(pacing.due(Duration::from_secs(5), Duration::from_millis(3), Some(1)));
1524    }
1525
1526    #[test]
1527    fn small_tail_defers_until_the_deadline() {
1528        let pacing = pacing(100, 4, 1_000, 60_000);
1529        assert!(!pacing.due(Duration::from_secs(30), Duration::ZERO, Some(999)));
1530        assert!(pacing.due(Duration::from_secs(60), Duration::ZERO, Some(999)));
1531    }
1532
1533    #[test]
1534    fn tail_at_threshold_publishes_at_base_pacing() {
1535        let pacing = pacing(100, 4, 1_000, 60_000);
1536        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, Some(1_000)));
1537    }
1538
1539    #[test]
1540    fn unknown_tail_publishes_at_base_pacing() {
1541        let pacing = pacing(100, 4, 1_000, 60_000);
1542        assert!(pacing.due(Duration::from_millis(100), Duration::ZERO, None));
1543    }
1544
1545    #[test]
1546    fn deadline_never_overrides_the_backoff_floor() {
1547        let pacing = pacing(100, 4, 1_000, 200);
1548        let last = Duration::from_millis(300);
1549        assert!(!pacing.due(Duration::from_millis(400), last, Some(1)));
1550        assert!(pacing.due(Duration::from_millis(1_200), last, Some(1)));
1551    }
1552}