Skip to main content

corium_transactor/
lib.rs

1//! Embedded single-writer transaction pipeline and index publisher, plus the
2//! networked transactor process (lease, gRPC services, indexing job).
3
4pub mod authz;
5pub mod backend;
6pub mod backup;
7pub mod keys;
8pub mod lease;
9pub mod metrics;
10pub mod node;
11pub mod server;
12#[cfg(feature = "cljrs")]
13pub mod txfn;
14
15pub use backend::{LogBackend, NodeStore, StorageConnectionError, StorageInfoConfig, StoreSpec};
16#[cfg(feature = "s3")]
17pub use backend::{S3ReadOnlyConfig, S3ReadOnlyCredentials};
18
19use corium_core::{EntityId, IndexOrder, KeywordInterner, Partition, Schema};
20use corium_db::{Db, FIRST_USER_ID, Idents};
21use corium_index::{Leaf, LeafId, Segment};
22use corium_log::{LogError, TransactionLog, TxRecord};
23use corium_store::{BlobId, BlobStore, RootStore, StoreError};
24use corium_tx::{PreparedTx, TxError, TxItem, prepare};
25use std::{
26    collections::HashMap,
27    sync::{Arc, Mutex, mpsc},
28    time::{SystemTime, UNIX_EPOCH},
29};
30use thiserror::Error;
31
32/// The covering indexes a database publishes, in the slot order [`DbRoot`]
33/// stores their blob ids in.
34const ORDERS: [IndexOrder; 4] = [
35    IndexOrder::Eavt,
36    IndexOrder::Aevt,
37    IndexOrder::Avet,
38    IndexOrder::Vaet,
39];
40
41/// Result delivered after a transaction is durable and visible.
42#[derive(Clone, Debug)]
43pub struct TxReport {
44    /// Database before the transaction.
45    pub db_before: Db,
46    /// Database including the transaction.
47    pub db_after: Db,
48    /// Prepared transaction and tempid map.
49    pub tx: PreparedTx,
50    /// Commit timestamp.
51    pub tx_instant: i64,
52}
53
54/// Pipeline errors.
55#[derive(Debug, Error)]
56pub enum TransactError {
57    /// Transaction rejected before durability.
58    #[error(transparent)]
59    Tx(#[from] TxError),
60    /// Durable log failed.
61    #[error(transparent)]
62    Log(#[from] LogError),
63    /// Index/root store failed.
64    #[error(transparent)]
65    Store(#[from] StoreError),
66    /// System clock predates the Unix epoch.
67    #[error("system clock is before Unix epoch")]
68    Clock,
69    /// An index-building worker failed before returning its result.
70    #[error("index task failed: {0}")]
71    IndexTask(String),
72    /// A synchronous caller raced an asynchronous transaction in progress.
73    #[error("an asynchronous transaction is already in progress")]
74    AsyncTransactionPending,
75    /// A newer lease version owns the database root; this writer is deposed.
76    #[error("deposed: database root is owned by lease version {published}")]
77    Deposed {
78        /// Lease version found on the published root.
79        published: u64,
80    },
81}
82
83/// Settles a transaction's `:db/txInstant` and materializes it as a datom.
84///
85/// Transaction data may assert its own instant against the transaction entity
86/// (`[:db/add "datomic.tx" :db/txInstant …]` — how an import dates backfilled
87/// transactions); otherwise the transactor stamps `max(now, last + 1)`. Either
88/// way the instant ends up in the committed datom set, so the log, the
89/// tx-report, every peer's live index, and the published snapshot all carry
90/// one representation of transaction time.
91///
92/// # Errors
93/// Returns [`TxError::TxInstantNotMonotonic`] when supplied data would move the
94/// transaction clock backwards.
95fn seal_tx_instant(
96    datoms: &mut Vec<corium_core::Datom>,
97    t: u64,
98    now_ms: i64,
99    last_instant: i64,
100) -> Result<i64, TxError> {
101    match corium_db::bootstrap::asserted_instant(t, datoms) {
102        Some(supplied) if supplied <= last_instant => Err(TxError::TxInstantNotMonotonic {
103            supplied,
104            last: last_instant,
105        }),
106        Some(supplied) => Ok(supplied),
107        None => {
108            let instant = now_ms.max(last_instant.saturating_add(1));
109            datoms.push(corium_db::bootstrap::tx_instant_datom(t, instant));
110            Ok(instant)
111        }
112    }
113}
114
115fn effective_tx_instant(record: &TxRecord) -> i64 {
116    corium_db::bootstrap::asserted_instant(record.t, &record.datoms).unwrap_or(record.tx_instant)
117}
118
119struct State {
120    db: Db,
121    next_user: u64,
122    last_instant: i64,
123    subscribers: Vec<mpsc::Sender<TxReport>>,
124    async_pending: bool,
125}
126
127struct AsyncPending<'a> {
128    state: &'a Mutex<State>,
129    active: bool,
130}
131
132impl Drop for AsyncPending<'_> {
133    fn drop(&mut self) {
134        if self.active {
135            self.state
136                .lock()
137                .unwrap_or_else(std::sync::PoisonError::into_inner)
138                .async_pending = false;
139        }
140    }
141}
142
143/// The next free user-partition entity id given a run of datoms and a floor,
144/// so allocation never revisits an id any of them used.
145fn next_user_id<'a>(datoms: impl Iterator<Item = &'a corium_core::Datom>, floor: u64) -> u64 {
146    datoms
147        .filter(|d| d.e.partition() == Partition::User as u32)
148        .map(|d| d.e.sequence() + 1)
149        .fold(floor, u64::max)
150}
151
152/// A transaction prepared against a [`BatchCursor`] but not yet durable,
153/// carrying its log record and the pieces needed to build a [`TxReport`] once
154/// the batch is durable.
155pub struct Prepared {
156    /// The record to make durable in the log.
157    pub record: TxRecord,
158    db_before: Db,
159    db_after: Db,
160    tx: PreparedTx,
161    tx_instant: i64,
162}
163
164/// A cursor for preparing a group-commit batch against an evolving in-memory
165/// value without touching the live one. Each [`Self::prepare`] validates
166/// against the effects of the earlier transactions in the batch (uniqueness,
167/// cardinality-one retraction, CAS), so a batch commits with exactly the
168/// semantics of committing the transactions one at a time. The caller makes
169/// the batch's records durable, then publishes the cursor with
170/// [`EmbeddedTransactor::install_batch`]; a batch that never installs leaves
171/// the live value untouched.
172pub struct BatchCursor {
173    db: Db,
174    next_user: u64,
175    last_instant: i64,
176}
177
178impl BatchCursor {
179    /// The value including every transaction prepared into this batch so far,
180    /// for expanding and converting the next transaction against it.
181    #[must_use]
182    pub fn db(&self) -> &Db {
183        &self.db
184    }
185
186    /// Prepares one transaction against the cursor, advancing the cursor's
187    /// in-memory value by it. `now_ms` is the wall clock in Unix milliseconds;
188    /// `:db/txInstant` stays monotone via `max(now, last + 1)`. On error the
189    /// cursor is unchanged, so a rejected transaction leaves the rest of the
190    /// batch unaffected.
191    ///
192    /// # Errors
193    /// Returns [`TxError`] when the transaction fails resolution or validation.
194    pub fn prepare(
195        &mut self,
196        items: impl IntoIterator<Item = TxItem>,
197        now_ms: i64,
198    ) -> Result<Prepared, TxError> {
199        let before = self.db.clone();
200        let t = before.basis_t() + 1;
201        let tx_id = EntityId::new(Partition::Tx as u32, t);
202        let mut prepared = prepare(&before, items, tx_id, self.next_user)?;
203        let tx_instant = seal_tx_instant(&mut prepared.datoms, t, now_ms, self.last_instant)?;
204        let after = before
205            .clone()
206            .with_transaction_at(t, tx_instant, &prepared.datoms);
207        self.next_user = prepared
208            .tempids
209            .values()
210            .filter(|e| e.partition() == Partition::User as u32)
211            .map(|e| e.sequence() + 1)
212            .max()
213            .unwrap_or(self.next_user)
214            .max(self.next_user);
215        self.last_instant = tx_instant;
216        self.db = after.clone();
217        Ok(Prepared {
218            record: TxRecord {
219                t,
220                tx_instant,
221                datoms: prepared.datoms.clone(),
222            },
223            db_before: before,
224            db_after: after,
225            tx: prepared,
226            tx_instant,
227        })
228    }
229}
230
231/// The snapshot this transactor last installed at the published root, kept so
232/// the next indexing pass folds the log tail into it instead of rebuilding
233/// every covering index from scratch.
234struct PublishedIndexes {
235    /// Lease version and index basis the published root carries; both are
236    /// checked against that root before the pass trusts anything below.
237    lease_version: u64,
238    basis_t: u64,
239    /// Segments in [`ORDERS`] slot order, whose leaves are the chunks named
240    /// by `manifests`.
241    segments: [Segment; 4],
242    /// Manifest blob id per index, matched against the stored root to prove
243    /// a live root still references the chunks this pass carries over — so
244    /// garbage collection cannot have swept one out from under it.
245    manifests: [BlobId; 4],
246    /// The chunk each leaf was published as. A leaf carried over from this
247    /// snapshot is already in the store under this id, so the next pass
248    /// neither re-encodes nor re-uploads it.
249    chunks: [HashMap<LeafId, BlobId>; 4],
250}
251
252impl PublishedIndexes {
253    /// Whether `root` is still exactly the root this snapshot published.
254    fn is_current(&self, root: Option<&DbRoot>, lease_version: u64) -> bool {
255        root.is_some_and(|root| {
256            self.lease_version == lease_version
257                && root.lease_version == lease_version
258                && root.index_basis_t == self.basis_t
259                && root.roots.as_ref() == Some(&self.manifests)
260        })
261    }
262}
263
264/// One index's chunk after a pass has decided what to do with it.
265enum ChunkPlan {
266    /// A leaf carried over from the last publication; already stored under
267    /// this id, so the pass neither encodes nor uploads it.
268    Published(BlobId),
269    /// A rebuilt leaf, encoded and awaiting upload.
270    Rebuilt(Vec<u8>),
271}
272
273/// A serialized, in-process transactor. The log append is the commit point.
274pub struct EmbeddedTransactor {
275    log: Arc<dyn TransactionLog>,
276    state: Mutex<State>,
277    async_commit: tokio::sync::Mutex<()>,
278    published: Mutex<Option<PublishedIndexes>>,
279}
280impl EmbeddedTransactor {
281    /// Recovers a transactor by replaying the durable log exactly once.
282    ///
283    /// # Errors
284    /// Returns an error when the durable log cannot be replayed.
285    pub fn recover(schema: Schema, log: Arc<dyn TransactionLog>) -> Result<Self, TransactError> {
286        Self::recover_from(Db::new(schema), log)
287    }
288
289    /// Recovers from an empty base database value (schema plus naming) by
290    /// replaying the durable log exactly once.
291    ///
292    /// # Errors
293    /// Returns an error when the durable log cannot be replayed.
294    pub fn recover_from(base: Db, log: Arc<dyn TransactionLog>) -> Result<Self, TransactError> {
295        let mut db = base;
296        let mut last_instant = i64::MIN;
297        for record in log.replay()? {
298            let tx_instant = effective_tx_instant(&record);
299            db = db.with_transaction_at(record.t, record.tx_instant, &record.datoms);
300            last_instant = last_instant.max(tx_instant);
301        }
302        // Allocation must resume past every id that ever appeared in the log,
303        // not just ids with current datoms; otherwise a fully retracted
304        // entity's id would be reused after a restart.
305        let next_user = next_user_id(db.recorded_datoms(), FIRST_USER_ID);
306        Ok(Self {
307            log,
308            state: Mutex::new(State {
309                db,
310                next_user,
311                last_instant,
312                subscribers: Vec::new(),
313                async_pending: false,
314            }),
315            async_commit: tokio::sync::Mutex::new(()),
316            published: Mutex::new(None),
317        })
318    }
319
320    /// Recovers a transactor through the log's asynchronous storage path.
321    ///
322    /// # Errors
323    /// Returns an error when the durable log cannot be replayed.
324    pub async fn recover_from_async(
325        base: Db,
326        log: Arc<dyn TransactionLog>,
327    ) -> Result<Self, TransactError> {
328        let records = log.replay_async().await?;
329        Ok(Self::recover_from_records(base, log, records))
330    }
331
332    fn recover_from_records(
333        mut db: Db,
334        log: Arc<dyn TransactionLog>,
335        records: Vec<TxRecord>,
336    ) -> Self {
337        let mut last_instant = i64::MIN;
338        for record in records {
339            let tx_instant = effective_tx_instant(&record);
340            db = db.with_transaction_at(record.t, record.tx_instant, &record.datoms);
341            last_instant = last_instant.max(tx_instant);
342        }
343        let next_user = next_user_id(db.recorded_datoms(), FIRST_USER_ID);
344        Self {
345            log,
346            state: Mutex::new(State {
347                db,
348                next_user,
349                last_instant,
350                subscribers: Vec::new(),
351                async_pending: false,
352            }),
353            async_commit: tokio::sync::Mutex::new(()),
354            published: Mutex::new(None),
355        }
356    }
357
358    /// Recovers from a published current-state snapshot plus the log tail,
359    /// replaying only transactions after the snapshot's basis instead of the
360    /// whole history — so open and restart cost scale with the tail, not the
361    /// database's age.
362    ///
363    /// `snapshot` is the current value at `snapshot.basis_t()` (typically
364    /// [`Db::from_current_snapshot`] materialized from the published EAVT
365    /// index). `next_entity_id` and `last_tx_instant` are the allocator and
366    /// transaction-time high-water marks recorded in the [`DbRoot`] at
367    /// publication (`DbRoot::next_entity_id` / `DbRoot::last_tx_instant`);
368    /// they carry the state a current-facts snapshot cannot: entities fully
369    /// retracted before the snapshot (whose ids must not be reused) and the
370    /// last commit's instant (for `:db/txInstant` monotonicity when the tail
371    /// is empty). Both are combined by `max` with whatever the replayed tail
372    /// reveals, so an over-estimate is safe and a stale hint can only make
373    /// allocation more conservative.
374    ///
375    /// The caller is responsible for opening `log` at the same lease version
376    /// it recovered the snapshot under, exactly as [`recover_from`] requires.
377    ///
378    /// # Errors
379    /// Returns an error when the log tail cannot be replayed.
380    ///
381    /// [`recover_from`]: Self::recover_from
382    pub fn recover_from_snapshot(
383        snapshot: Db,
384        next_entity_id: u64,
385        last_tx_instant: i64,
386        log: Arc<dyn TransactionLog>,
387    ) -> Result<Self, TransactError> {
388        let mut db = snapshot;
389        let index_basis = db.basis_t();
390        let mut last_instant = last_tx_instant;
391        // The snapshot's live datoms are already covered by the persisted
392        // `next_entity_id`; only the tail can introduce ids past it.
393        let mut next_user = next_entity_id.max(FIRST_USER_ID);
394        for record in log.tx_range(index_basis + 1, None)? {
395            let tx_instant = effective_tx_instant(&record);
396            db = db.with_transaction_at(record.t, record.tx_instant, &record.datoms);
397            last_instant = last_instant.max(tx_instant);
398            next_user = next_user.max(next_user_id(record.datoms.iter(), next_user));
399        }
400        Ok(Self {
401            log,
402            state: Mutex::new(State {
403                db,
404                next_user,
405                last_instant,
406                subscribers: Vec::new(),
407                async_pending: false,
408            }),
409            async_commit: tokio::sync::Mutex::new(()),
410            published: Mutex::new(None),
411        })
412    }
413
414    /// Recovers from a published snapshot plus an asynchronously read log
415    /// tail.
416    ///
417    /// # Errors
418    /// Returns an error when the log tail cannot be replayed.
419    pub async fn recover_from_snapshot_async(
420        snapshot: Db,
421        next_entity_id: u64,
422        last_tx_instant: i64,
423        log: Arc<dyn TransactionLog>,
424    ) -> Result<Self, TransactError> {
425        let index_basis = snapshot.basis_t();
426        let records = log.tx_range_async(index_basis + 1, None).await?;
427        let mut db = snapshot;
428        let mut last_instant = last_tx_instant;
429        let mut next_user = next_entity_id.max(FIRST_USER_ID);
430        for record in records {
431            let tx_instant = effective_tx_instant(&record);
432            db = db.with_transaction_at(record.t, record.tx_instant, &record.datoms);
433            last_instant = last_instant.max(tx_instant);
434            next_user = next_user.max(next_user_id(record.datoms.iter(), next_user));
435        }
436        Ok(Self {
437            log,
438            state: Mutex::new(State {
439                db,
440                next_user,
441                last_instant,
442                subscribers: Vec::new(),
443                async_pending: false,
444            }),
445            async_commit: tokio::sync::Mutex::new(()),
446            published: Mutex::new(None),
447        })
448    }
449
450    /// Captures a consistent recovery snapshot: the current database value
451    /// with the allocator and transaction-time high-water marks that a
452    /// snapshot-only recovery would otherwise lose, all read under one lock.
453    fn recovery_snapshot(&self) -> (Db, u64, i64) {
454        let state = self
455            .state
456            .lock()
457            .unwrap_or_else(std::sync::PoisonError::into_inner);
458        (state.db.clone(), state.next_user, state.last_instant)
459    }
460    /// Returns the current immutable database value.
461    #[must_use]
462    pub fn db(&self) -> Db {
463        self.state
464            .lock()
465            .unwrap_or_else(std::sync::PoisonError::into_inner)
466            .db
467            .clone()
468    }
469    /// Subscribes to reports for transactions committed after this call.
470    pub fn subscribe(&self) -> mpsc::Receiver<TxReport> {
471        let (tx, rx) = mpsc::channel();
472        self.state
473            .lock()
474            .unwrap_or_else(std::sync::PoisonError::into_inner)
475            .subscribers
476            .push(tx);
477        rx
478    }
479    /// Validates, durably appends, applies, and reports a transaction.
480    ///
481    /// # Errors
482    /// Returns an error for rejected transaction data, clock failure, or when
483    /// the durable append fails. No report is sent on error.
484    pub fn transact(
485        &self,
486        items: impl IntoIterator<Item = TxItem>,
487    ) -> Result<TxReport, TransactError> {
488        let mut state = self
489            .state
490            .lock()
491            .unwrap_or_else(std::sync::PoisonError::into_inner);
492        if state.async_pending {
493            return Err(TransactError::AsyncTransactionPending);
494        }
495        let before = state.db.clone();
496        let t = before.basis_t() + 1;
497        let tx_id = EntityId::new(Partition::Tx as u32, t);
498        let mut prepared = prepare(&before, items, tx_id, state.next_user)?;
499        let millis = i64::try_from(
500            SystemTime::now()
501                .duration_since(UNIX_EPOCH)
502                .map_err(|_| TransactError::Clock)?
503                .as_millis(),
504        )
505        .unwrap_or(i64::MAX);
506        let tx_instant = seal_tx_instant(&mut prepared.datoms, t, millis, state.last_instant)?;
507        self.log.append(&TxRecord {
508            t,
509            tx_instant,
510            datoms: prepared.datoms.clone(),
511        })?;
512        state.db = before.with_transaction_at(t, tx_instant, &prepared.datoms);
513        state.last_instant = tx_instant;
514        state.next_user = prepared
515            .tempids
516            .values()
517            .filter(|e| e.partition() == Partition::User as u32)
518            .map(|e| e.sequence() + 1)
519            .max()
520            .unwrap_or(state.next_user)
521            .max(state.next_user);
522        let report = TxReport {
523            db_before: before,
524            db_after: state.db.clone(),
525            tx: prepared,
526            tx_instant,
527        };
528        state
529            .subscribers
530            .retain(|subscriber| subscriber.send(report.clone()).is_ok());
531        Ok(report)
532    }
533
534    /// Validates under a short state lock, awaits durability without holding
535    /// that lock, then atomically publishes the durable transaction in memory.
536    /// Async calls are serialized here so standalone callers have the same
537    /// single-writer guarantee as node-hosted callers.
538    ///
539    /// # Errors
540    /// Returns an error for rejected transaction data, clock failure, or when
541    /// the durable append fails. No report is sent on error.
542    pub async fn transact_async(
543        &self,
544        items: impl IntoIterator<Item = TxItem>,
545    ) -> Result<TxReport, TransactError> {
546        let _commit = self.async_commit.lock().await;
547        let (before, prepared, t, tx_instant) = {
548            let mut state = self
549                .state
550                .lock()
551                .unwrap_or_else(std::sync::PoisonError::into_inner);
552            if state.async_pending {
553                return Err(TransactError::AsyncTransactionPending);
554            }
555            let before = state.db.clone();
556            let t = before.basis_t() + 1;
557            let tx_id = EntityId::new(Partition::Tx as u32, t);
558            let mut prepared = prepare(&before, items, tx_id, state.next_user)?;
559            let millis = i64::try_from(
560                SystemTime::now()
561                    .duration_since(UNIX_EPOCH)
562                    .map_err(|_| TransactError::Clock)?
563                    .as_millis(),
564            )
565            .unwrap_or(i64::MAX);
566            let tx_instant = seal_tx_instant(&mut prepared.datoms, t, millis, state.last_instant)?;
567            state.async_pending = true;
568            (before, prepared, t, tx_instant)
569        };
570        let record = TxRecord {
571            t,
572            tx_instant,
573            datoms: prepared.datoms.clone(),
574        };
575        let mut pending = AsyncPending {
576            state: &self.state,
577            active: true,
578        };
579        self.log.append_async(&record).await?;
580        let mut state = self
581            .state
582            .lock()
583            .unwrap_or_else(std::sync::PoisonError::into_inner);
584        debug_assert_eq!(state.db.basis_t() + 1, t);
585        // Apply to the live value so a naming-only update that ran while the
586        // append was pending is preserved; transaction-bearing state cannot
587        // change while `async_pending` is set.
588        state.db = state
589            .db
590            .clone()
591            .with_transaction_at(t, tx_instant, &prepared.datoms);
592        state.last_instant = tx_instant;
593        state.next_user = prepared
594            .tempids
595            .values()
596            .filter(|e| e.partition() == Partition::User as u32)
597            .map(|e| e.sequence() + 1)
598            .max()
599            .unwrap_or(state.next_user)
600            .max(state.next_user);
601        state.async_pending = false;
602        pending.active = false;
603        let report = TxReport {
604            db_before: before,
605            db_after: state.db.clone(),
606            tx: prepared,
607            tx_instant,
608        };
609        state
610            .subscribers
611            .retain(|subscriber| subscriber.send(report.clone()).is_ok());
612        Ok(report)
613    }
614    /// Snapshots the live value and allocation watermarks for preparing a
615    /// group-commit batch. The returned [`BatchCursor`] evolves privately;
616    /// [`Self::install_batch`] publishes it after the batch is durable.
617    #[must_use]
618    pub fn batch_cursor(&self) -> BatchCursor {
619        let state = self
620            .state
621            .lock()
622            .unwrap_or_else(std::sync::PoisonError::into_inner);
623        BatchCursor {
624            db: state.db.clone(),
625            next_user: state.next_user,
626            last_instant: state.last_instant,
627        }
628    }
629
630    /// Publishes a durably-committed batch: installs the cursor's value as the
631    /// live value, advances the allocation and transaction-time watermarks,
632    /// fans the reports out to subscribers, and returns them in batch order.
633    /// The caller must have made every record in `prepared` durable and
634    /// verified ownership first. The cursor was snapshotted from, and advanced
635    /// past, the live value under the same single-writer serialization, so
636    /// installing it cannot lose a concurrent commit.
637    pub fn install_batch(&self, cursor: BatchCursor, prepared: Vec<Prepared>) -> Vec<TxReport> {
638        let mut state = self
639            .state
640            .lock()
641            .unwrap_or_else(std::sync::PoisonError::into_inner);
642        state.db = cursor.db;
643        state.next_user = state.next_user.max(cursor.next_user);
644        state.last_instant = state.last_instant.max(cursor.last_instant);
645        let reports: Vec<TxReport> = prepared
646            .into_iter()
647            .map(|p| TxReport {
648                db_before: p.db_before,
649                db_after: p.db_after,
650                tx: p.tx,
651                tx_instant: p.tx_instant,
652            })
653            .collect();
654        for report in &reports {
655            state
656                .subscribers
657                .retain(|subscriber| subscriber.send(report.clone()).is_ok());
658        }
659        reports
660    }
661
662    /// Replaces the ident/keyword naming attached to the current database
663    /// value (used when the boundary interns new keywords).
664    pub fn update_naming(&self, idents: Idents, interner: KeywordInterner) {
665        let mut state = self
666            .state
667            .lock()
668            .unwrap_or_else(std::sync::PoisonError::into_inner);
669        state.db = state.db.clone().with_naming(idents, interner);
670    }
671
672    /// Publishes a consistent snapshot of all four covering indexes.
673    ///
674    /// The pass folds the log tail since the last publication into the
675    /// segments that publication produced ([`corium_index::Segment::apply`]),
676    /// so its cost tracks the tail rather than the size of the database: a
677    /// segment's leaf is exactly one published chunk, and a leaf the tail did
678    /// not touch is carried over by handle and keeps the blob id it already
679    /// has. Only rebuilt leaves are encoded, hashed, and uploaded, under a
680    /// manifest blob naming every chunk in key order.
681    ///
682    /// A carried-over chunk is published by id without being re-uploaded, and
683    /// nothing but the root that names it keeps it from being swept. So a pass
684    /// carries chunks over only while it can prove that root is live: it
685    /// requires the published root to be the one this transactor last
686    /// installed, *and* pins that index state through the CAS, which installs
687    /// only if the root never changed in between. A takeover, a concurrent
688    /// publisher, or a root rolled back therefore costs a rebuild rather than
689    /// a manifest naming chunks the sweep is free to delete.
690    ///
691    /// Rebuilding is always available and always correct — it is also what the
692    /// first publication of a process does — and it reuses no chunk id, so it
693    /// cannot be raced this way. It re-encodes every chunk, but reproduces the
694    /// boundaries the previous publication cut, so it re-uploads only what
695    /// genuinely changed.
696    ///
697    /// Blobs are uploaded before the root CAS. Transactions may continue while
698    /// the immutable snapshot is encoded; a later run indexes any remaining
699    /// log tail.
700    ///
701    /// Publication is fenced by `lease_version` and monotone in
702    /// `index_basis_t`: a root already published under a newer lease version
703    /// deposes this writer ([`TransactError::Deposed`]); a root at an equal
704    /// or newer basis (or one that wins a concurrent CAS race) leaves this
705    /// snapshot's blobs for garbage collection. The freshly built root is
706    /// returned when it, or a newer basis, is installed.
707    ///
708    /// # Errors
709    /// Returns an error if a blob upload, root read, or fenced publication fails.
710    pub async fn publish_indexes(
711        &self,
712        store: &(impl BlobStore + RootStore),
713        root_name: &str,
714        lease_version: u64,
715    ) -> Result<DbRoot, TransactError> {
716        let previous = self
717            .published
718            .lock()
719            .unwrap_or_else(std::sync::PoisonError::into_inner)
720            .take();
721        if let PassOutcome::Published(root) = self
722            .publish_pass(store, root_name, lease_version, previous)
723            .await?
724        {
725            return Ok(root);
726        }
727        // The root's index state moved under a pass that was carrying chunks
728        // over from the last publication, so nothing was installed. Rebuilding
729        // reuses no chunk id, so the retry has nothing left to be raced for.
730        match self
731            .publish_pass(store, root_name, lease_version, None)
732            .await?
733        {
734            PassOutcome::Published(root) => Ok(root),
735            PassOutcome::Raced => {
736                unreachable!("a pass that carries nothing over publishes unconditionally")
737            }
738        }
739    }
740
741    /// One publication attempt, optionally folding the tail into `previous`
742    /// rather than rebuilding.
743    async fn publish_pass(
744        &self,
745        store: &(impl BlobStore + RootStore),
746        root_name: &str,
747        lease_version: u64,
748        previous: Option<PublishedIndexes>,
749    ) -> Result<PassOutcome, TransactError> {
750        let stored = store
751            .get_root(root_name)
752            .await?
753            .as_deref()
754            .and_then(DbRoot::decode);
755        if let Some(published) = stored.as_ref().map(|root| root.lease_version)
756            && published > lease_version
757        {
758            return Err(TransactError::Deposed { published });
759        }
760        let (snapshot, next_entity_id, last_tx_instant) = self.recovery_snapshot();
761        let basis_t = snapshot.basis_t();
762        let previous = previous.filter(|previous| {
763            previous.is_current(stored.as_ref(), lease_version) && previous.basis_t <= basis_t
764        });
765        // Carrying a chunk over publishes its blob id without re-uploading it,
766        // and nothing but the root that names it keeps it from being swept.
767        // Pinning the index state this pass validated makes the root CAS the
768        // fence for those blobs too: it installs only if the root never
769        // changed in between, so the carried chunks stayed referenced by the
770        // live root from the check right through to the write.
771        let pinned = previous
772            .as_ref()
773            .map(|_| RootIndexState::of(stored.as_ref()));
774
775        let (segments, planned) =
776            tokio::task::spawn_blocking(move || plan_indexes(&snapshot, previous.as_ref()))
777                .await
778                .map_err(|error| TransactError::IndexTask(error.to_string()))?;
779
780        let mut manifests = Vec::with_capacity(ORDERS.len());
781        let mut chunk_ids = Vec::with_capacity(ORDERS.len());
782        for chunks in &planned {
783            let mut children = Vec::with_capacity(chunks.len());
784            for chunk in chunks {
785                children.push(match chunk {
786                    ChunkPlan::Published(id) => id.clone(),
787                    ChunkPlan::Rebuilt(bytes) => store.put_if_absent(bytes).await?,
788                });
789            }
790            let manifest = corium_store::encode_index_manifest(&children);
791            manifests.push(store.put_if_absent(&manifest).await?);
792            chunk_ids.push(children);
793        }
794        let manifests: [BlobId; 4] = manifests
795            .try_into()
796            .unwrap_or_else(|_| unreachable!("one manifest per covering index"));
797        let root = DbRoot {
798            format_version: corium_store::FORMAT_VERSION,
799            lease_version,
800            owner: String::new(),
801            lease_expires_unix_ms: 0,
802            owner_endpoint: String::new(),
803            index_basis_t: basis_t,
804            roots: Some(manifests.clone()),
805            // Recovery hints for opening from this root without full replay.
806            next_entity_id,
807            last_tx_instant,
808            // Owned by the key manifest, not by publication;
809            // `publish_root_pinned` carries the stored value forward.
810            key_manifest_version: 0,
811        };
812        let outcome = publish_root_pinned(store, root_name, &root, pinned.as_ref()).await?;
813        if outcome == RootPublication::Raced {
814            return Ok(PassOutcome::Raced);
815        }
816        let next = PublishedIndexes {
817            lease_version,
818            basis_t,
819            chunks: chunk_ids_by_leaf(&segments, chunk_ids),
820            segments,
821            manifests,
822        };
823        // Keep the pass's segments only when the published root is the one
824        // that describes them — either because this CAS installed it, or
825        // because a publication at this basis had already stored the same
826        // manifests (an indexing run over an unchanged basis). Anything else
827        // leaves these chunks unreferenced, so the next pass must rebuild
828        // rather than assume they survived a sweep.
829        if outcome == RootPublication::Installed || next.is_current(stored.as_ref(), lease_version)
830        {
831            *self
832                .published
833                .lock()
834                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(next);
835        }
836        Ok(PassOutcome::Published(root))
837    }
838}
839
840/// What one publication attempt produced.
841enum PassOutcome {
842    /// The attempt published this root (installed, or superseded by an equal
843    /// or newer basis).
844    Published(DbRoot),
845    /// The attempt carried chunks over from an earlier publication and the
846    /// root stopped vouching for them before the CAS, so nothing was
847    /// installed and the caller must retry from a rebuild.
848    Raced,
849}
850
851/// The published index state a publication pins the stored root to.
852///
853/// Only the index roots and their basis: the lease fields of the same record
854/// change under an ordinary renewal, which neither drops a chunk nor makes a
855/// carried blob id unsafe.
856#[derive(Clone, Debug, Eq, PartialEq)]
857struct RootIndexState {
858    index_basis_t: u64,
859    roots: Option<[BlobId; 4]>,
860}
861
862impl RootIndexState {
863    fn of(root: Option<&DbRoot>) -> Self {
864        Self {
865            index_basis_t: root.map_or(0, |root| root.index_basis_t),
866            roots: root.and_then(|root| root.roots.clone()),
867        }
868    }
869}
870
871/// Builds the four covering-index segments for `snapshot` and decides, leaf by
872/// leaf, which chunks the pass has to encode.
873///
874/// With a usable previous publication this folds only the tail since its basis
875/// into each segment; without one it rebuilds each segment from the database's
876/// own covering index, which is already in key order — so even the fallback
877/// path never sorts. Either way the datoms are read in place and kept only as
878/// the key they encode to, so a pass never copies the facts it indexes.
879fn plan_indexes(
880    snapshot: &Db,
881    previous: Option<&PublishedIndexes>,
882) -> ([Segment; 4], [Vec<ChunkPlan>; 4]) {
883    let mut segments: Vec<Segment> = Vec::with_capacity(ORDERS.len());
884    let mut planned: Vec<Vec<ChunkPlan>> = Vec::with_capacity(ORDERS.len());
885    for (slot, order) in ORDERS.into_iter().enumerate() {
886        let segment = match previous {
887            Some(previous) => previous.segments[slot].apply_ref(
888                order,
889                snapshot
890                    .recorded_since(previous.basis_t)
891                    .filter(|datom| corium_db::covered(snapshot.schema(), order, datom)),
892            ),
893            None => Segment::from_sorted_ref(order, snapshot.datoms_at(order)),
894        };
895        let stored = previous.map(|previous| &previous.chunks[slot]);
896        planned.push(
897            segment
898                .leaves()
899                .map(|leaf| plan_chunk(leaf, stored))
900                .collect(),
901        );
902        segments.push(segment);
903    }
904    (
905        segments
906            .try_into()
907            .unwrap_or_else(|_| unreachable!("one segment per covering index")),
908        planned
909            .try_into()
910            .unwrap_or_else(|_| unreachable!("one chunk plan per covering index")),
911    )
912}
913
914/// Reuses the blob id a leaf was last published under, or encodes the leaf
915/// when the pass rebuilt it.
916fn plan_chunk(leaf: &Leaf, stored: Option<&HashMap<LeafId, BlobId>>) -> ChunkPlan {
917    match stored.and_then(|stored| stored.get(&leaf.id())) {
918        Some(id) => ChunkPlan::Published(id.clone()),
919        None => ChunkPlan::Rebuilt(corium_store::encode_segment_chunk(
920            leaf.keys().iter().map(Vec::as_slice),
921        )),
922    }
923}
924
925/// Indexes each segment's published chunk ids by leaf, so the next pass can
926/// recognize a carried-over leaf as a chunk it has already stored.
927fn chunk_ids_by_leaf(
928    segments: &[Segment; 4],
929    chunk_ids: Vec<Vec<BlobId>>,
930) -> [HashMap<LeafId, BlobId>; 4] {
931    let mut per_index = chunk_ids.into_iter();
932    std::array::from_fn(|slot| {
933        segments[slot]
934            .leaves()
935            .map(Leaf::id)
936            .zip(per_index.next().unwrap_or_default())
937            .collect()
938    })
939}
940
941/// Whether a fenced root publication installed the root it was given.
942#[derive(Clone, Copy, Debug, Eq, PartialEq)]
943pub enum RootPublication {
944    /// The published root is now this one.
945    Installed,
946    /// A root at an equal or newer basis was already published, so this one
947    /// was not installed and the blobs behind it are left for garbage
948    /// collection.
949    Superseded,
950    /// The publication pinned the index state it was replacing and that state
951    /// changed, so this root was not installed. Only a caller that supplies a
952    /// pin can see this; [`publish_root`] never returns it.
953    Raced,
954}
955
956/// Publishes `root` under the fencing rules described on
957/// [`EmbeddedTransactor::publish_indexes`].
958///
959/// # Errors
960/// Returns [`TransactError::Deposed`] when a newer lease version owns the
961/// root, or a store error when the CAS cannot be completed.
962pub async fn publish_root(
963    store: &dyn RootStore,
964    root_name: &str,
965    root: &DbRoot,
966) -> Result<RootPublication, TransactError> {
967    publish_root_pinned(store, root_name, root, None).await
968}
969
970/// Publishes `root`, optionally only while the stored record still carries
971/// `pinned` as its index state.
972///
973/// A publication that names blobs it did not upload — an indexing pass
974/// carrying chunks over from the last one — has to pin: those blobs are kept
975/// alive only by the root that references them, so installing over a root
976/// that had already dropped them would leave the live root pointing at chunks
977/// the sweep is free to delete. The pin is re-checked against the record read
978/// immediately before each CAS attempt, and the CAS is conditional on exactly
979/// those bytes, so there is no window between the check and the write.
980///
981/// The pin covers the index roots and their basis, not the lease fields of
982/// the same record: an ordinary renewal rewrites those, and losing a pass to
983/// one would be a needless rebuild.
984async fn publish_root_pinned(
985    store: &dyn RootStore,
986    root_name: &str,
987    root: &DbRoot,
988    pinned: Option<&RootIndexState>,
989) -> Result<RootPublication, TransactError> {
990    loop {
991        let previous = store.get_root(root_name).await?;
992        let stored = previous.as_deref().and_then(DbRoot::decode);
993        if let Some(pinned) = pinned
994            && RootIndexState::of(stored.as_ref()) != *pinned
995        {
996            return Ok(RootPublication::Raced);
997        }
998        let mut next = root.clone();
999        if let Some(stored) = stored {
1000            // The key manifest changes independently of the lease and of
1001            // publication, so its generation always comes from the stored
1002            // record rather than from the root being installed.
1003            next.key_manifest_version = stored.key_manifest_version;
1004            if stored.lease_version > root.lease_version {
1005                return Err(TransactError::Deposed {
1006                    published: stored.lease_version,
1007                });
1008            }
1009            if stored.lease_version == root.lease_version
1010                && stored.index_basis_t >= root.index_basis_t
1011            {
1012                return Ok(RootPublication::Superseded);
1013            }
1014            // The stored record carries the live lease fields (renewals CAS
1015            // the same key); publication must not clobber them.
1016            if stored.lease_version == root.lease_version {
1017                next.owner = stored.owner;
1018                next.lease_expires_unix_ms = stored.lease_expires_unix_ms;
1019                next.owner_endpoint = stored.owner_endpoint;
1020            }
1021        }
1022        match store
1023            .cas_root(root_name, previous.as_deref(), &next.encode())
1024            .await
1025        {
1026            Ok(()) => return Ok(RootPublication::Installed),
1027            Err(StoreError::CasFailed { .. }) => {}
1028            Err(error) => return Err(error.into()),
1029        }
1030    }
1031}
1032
1033pub use corium_store::{DbRoot, db_root_name};