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