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