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 backend;
5pub mod backup;
6pub mod lease;
7pub mod metrics;
8pub mod node;
9pub mod server;
10
11pub use backend::{LogBackend, NodeStore, StoreSpec};
12
13use corium_core::{EntityId, IndexOrder, KeywordInterner, Partition, Schema};
14use corium_db::{Db, FIRST_USER_ID, Idents};
15use corium_index::Segment;
16use corium_log::{LogError, TransactionLog, TxRecord};
17use corium_store::{BlobStore, RootStore, StoreError};
18use corium_tx::{PreparedTx, TxError, TxItem, prepare};
19use std::{
20    sync::{Arc, Mutex, mpsc},
21    time::{SystemTime, UNIX_EPOCH},
22};
23use thiserror::Error;
24
25/// Result delivered after a transaction is durable and visible.
26#[derive(Clone, Debug)]
27pub struct TxReport {
28    /// Database before the transaction.
29    pub db_before: Db,
30    /// Database including the transaction.
31    pub db_after: Db,
32    /// Prepared transaction and tempid map.
33    pub tx: PreparedTx,
34    /// Commit timestamp.
35    pub tx_instant: i64,
36}
37
38/// Pipeline errors.
39#[derive(Debug, Error)]
40pub enum TransactError {
41    /// Transaction rejected before durability.
42    #[error(transparent)]
43    Tx(#[from] TxError),
44    /// Durable log failed.
45    #[error(transparent)]
46    Log(#[from] LogError),
47    /// Index/root store failed.
48    #[error(transparent)]
49    Store(#[from] StoreError),
50    /// System clock predates the Unix epoch.
51    #[error("system clock is before Unix epoch")]
52    Clock,
53    /// An index-building worker failed before returning its result.
54    #[error("index task failed: {0}")]
55    IndexTask(String),
56    /// A synchronous caller raced an asynchronous transaction in progress.
57    #[error("an asynchronous transaction is already in progress")]
58    AsyncTransactionPending,
59    /// A newer lease version owns the database root; this writer is deposed.
60    #[error("deposed: database root is owned by lease version {published}")]
61    Deposed {
62        /// Lease version found on the published root.
63        published: u64,
64    },
65}
66
67struct State {
68    db: Db,
69    next_user: u64,
70    last_instant: i64,
71    subscribers: Vec<mpsc::Sender<TxReport>>,
72    async_pending: bool,
73}
74
75struct AsyncPending<'a> {
76    state: &'a Mutex<State>,
77    active: bool,
78}
79
80impl Drop for AsyncPending<'_> {
81    fn drop(&mut self) {
82        if self.active {
83            self.state
84                .lock()
85                .unwrap_or_else(std::sync::PoisonError::into_inner)
86                .async_pending = false;
87        }
88    }
89}
90
91/// The next free user-partition entity id given a run of datoms and a floor,
92/// so allocation never revisits an id any of them used.
93fn next_user_id<'a>(datoms: impl Iterator<Item = &'a corium_core::Datom>, floor: u64) -> u64 {
94    datoms
95        .filter(|d| d.e.partition() == Partition::User as u32)
96        .map(|d| d.e.sequence() + 1)
97        .fold(floor, u64::max)
98}
99
100/// A serialized, in-process transactor. The log append is the commit point.
101pub struct EmbeddedTransactor {
102    log: Arc<dyn TransactionLog>,
103    state: Mutex<State>,
104    async_commit: tokio::sync::Mutex<()>,
105}
106impl EmbeddedTransactor {
107    /// Recovers a transactor by replaying the durable log exactly once.
108    ///
109    /// # Errors
110    /// Returns an error when the durable log cannot be replayed.
111    pub fn recover(schema: Schema, log: Arc<dyn TransactionLog>) -> Result<Self, TransactError> {
112        Self::recover_from(Db::new(schema), log)
113    }
114
115    /// Recovers from an empty base database value (schema plus naming) by
116    /// replaying the durable log exactly once.
117    ///
118    /// # Errors
119    /// Returns an error when the durable log cannot be replayed.
120    pub fn recover_from(base: Db, log: Arc<dyn TransactionLog>) -> Result<Self, TransactError> {
121        let mut db = base;
122        let mut last_instant = i64::MIN;
123        for record in log.replay()? {
124            db = db.with_transaction(record.t, &record.datoms);
125            last_instant = last_instant.max(record.tx_instant);
126        }
127        // Allocation must resume past every id that ever appeared in the log,
128        // not just ids with current datoms; otherwise a fully retracted
129        // entity's id would be reused after a restart.
130        let next_user = next_user_id(db.recorded_datoms(), FIRST_USER_ID);
131        Ok(Self {
132            log,
133            state: Mutex::new(State {
134                db,
135                next_user,
136                last_instant,
137                subscribers: Vec::new(),
138                async_pending: false,
139            }),
140            async_commit: tokio::sync::Mutex::new(()),
141        })
142    }
143
144    /// Recovers a transactor through the log's asynchronous storage path.
145    ///
146    /// # Errors
147    /// Returns an error when the durable log cannot be replayed.
148    pub async fn recover_from_async(
149        base: Db,
150        log: Arc<dyn TransactionLog>,
151    ) -> Result<Self, TransactError> {
152        let records = log.replay_async().await?;
153        Ok(Self::recover_from_records(base, log, records))
154    }
155
156    fn recover_from_records(
157        mut db: Db,
158        log: Arc<dyn TransactionLog>,
159        records: Vec<TxRecord>,
160    ) -> Self {
161        let mut last_instant = i64::MIN;
162        for record in records {
163            db = db.with_transaction(record.t, &record.datoms);
164            last_instant = last_instant.max(record.tx_instant);
165        }
166        let next_user = next_user_id(db.recorded_datoms(), FIRST_USER_ID);
167        Self {
168            log,
169            state: Mutex::new(State {
170                db,
171                next_user,
172                last_instant,
173                subscribers: Vec::new(),
174                async_pending: false,
175            }),
176            async_commit: tokio::sync::Mutex::new(()),
177        }
178    }
179
180    /// Recovers from a published current-state snapshot plus the log tail,
181    /// replaying only transactions after the snapshot's basis instead of the
182    /// whole history — so open and restart cost scale with the tail, not the
183    /// database's age.
184    ///
185    /// `snapshot` is the current value at `snapshot.basis_t()` (typically
186    /// [`Db::from_current_snapshot`] materialized from the published EAVT
187    /// index). `next_entity_id` and `last_tx_instant` are the allocator and
188    /// transaction-time high-water marks recorded in the [`DbRoot`] at
189    /// publication (`DbRoot::next_entity_id` / `DbRoot::last_tx_instant`);
190    /// they carry the state a current-facts snapshot cannot: entities fully
191    /// retracted before the snapshot (whose ids must not be reused) and the
192    /// last commit's instant (for `:db/txInstant` monotonicity when the tail
193    /// is empty). Both are combined by `max` with whatever the replayed tail
194    /// reveals, so an over-estimate is safe and a stale hint can only make
195    /// allocation more conservative.
196    ///
197    /// The caller is responsible for opening `log` at the same lease version
198    /// it recovered the snapshot under, exactly as [`recover_from`] requires.
199    ///
200    /// # Errors
201    /// Returns an error when the log tail cannot be replayed.
202    ///
203    /// [`recover_from`]: Self::recover_from
204    pub fn recover_from_snapshot(
205        snapshot: Db,
206        next_entity_id: u64,
207        last_tx_instant: i64,
208        log: Arc<dyn TransactionLog>,
209    ) -> Result<Self, TransactError> {
210        let mut db = snapshot;
211        let index_basis = db.basis_t();
212        let mut last_instant = last_tx_instant;
213        // The snapshot's live datoms are already covered by the persisted
214        // `next_entity_id`; only the tail can introduce ids past it.
215        let mut next_user = next_entity_id.max(FIRST_USER_ID);
216        for record in log.tx_range(index_basis + 1, None)? {
217            db = db.with_transaction(record.t, &record.datoms);
218            last_instant = last_instant.max(record.tx_instant);
219            next_user = next_user.max(next_user_id(record.datoms.iter(), next_user));
220        }
221        Ok(Self {
222            log,
223            state: Mutex::new(State {
224                db,
225                next_user,
226                last_instant,
227                subscribers: Vec::new(),
228                async_pending: false,
229            }),
230            async_commit: tokio::sync::Mutex::new(()),
231        })
232    }
233
234    /// Recovers from a published snapshot plus an asynchronously read log
235    /// tail.
236    ///
237    /// # Errors
238    /// Returns an error when the log tail cannot be replayed.
239    pub async fn recover_from_snapshot_async(
240        snapshot: Db,
241        next_entity_id: u64,
242        last_tx_instant: i64,
243        log: Arc<dyn TransactionLog>,
244    ) -> Result<Self, TransactError> {
245        let index_basis = snapshot.basis_t();
246        let records = log.tx_range_async(index_basis + 1, None).await?;
247        let mut db = snapshot;
248        let mut last_instant = last_tx_instant;
249        let mut next_user = next_entity_id.max(FIRST_USER_ID);
250        for record in records {
251            db = db.with_transaction(record.t, &record.datoms);
252            last_instant = last_instant.max(record.tx_instant);
253            next_user = next_user.max(next_user_id(record.datoms.iter(), next_user));
254        }
255        Ok(Self {
256            log,
257            state: Mutex::new(State {
258                db,
259                next_user,
260                last_instant,
261                subscribers: Vec::new(),
262                async_pending: false,
263            }),
264            async_commit: tokio::sync::Mutex::new(()),
265        })
266    }
267
268    /// Captures a consistent recovery snapshot: the current database value
269    /// with the allocator and transaction-time high-water marks that a
270    /// snapshot-only recovery would otherwise lose, all read under one lock.
271    fn recovery_snapshot(&self) -> (Db, u64, i64) {
272        let state = self
273            .state
274            .lock()
275            .unwrap_or_else(std::sync::PoisonError::into_inner);
276        (state.db.clone(), state.next_user, state.last_instant)
277    }
278    /// Returns the current immutable database value.
279    #[must_use]
280    pub fn db(&self) -> Db {
281        self.state
282            .lock()
283            .unwrap_or_else(std::sync::PoisonError::into_inner)
284            .db
285            .clone()
286    }
287    /// Subscribes to reports for transactions committed after this call.
288    pub fn subscribe(&self) -> mpsc::Receiver<TxReport> {
289        let (tx, rx) = mpsc::channel();
290        self.state
291            .lock()
292            .unwrap_or_else(std::sync::PoisonError::into_inner)
293            .subscribers
294            .push(tx);
295        rx
296    }
297    /// Validates, durably appends, applies, and reports a transaction.
298    ///
299    /// # Errors
300    /// Returns an error for rejected transaction data, clock failure, or when
301    /// the durable append fails. No report is sent on error.
302    pub fn transact(
303        &self,
304        items: impl IntoIterator<Item = TxItem>,
305    ) -> Result<TxReport, TransactError> {
306        let mut state = self
307            .state
308            .lock()
309            .unwrap_or_else(std::sync::PoisonError::into_inner);
310        if state.async_pending {
311            return Err(TransactError::AsyncTransactionPending);
312        }
313        let before = state.db.clone();
314        let t = before.basis_t() + 1;
315        let tx_id = EntityId::new(Partition::Tx as u32, t);
316        let prepared = prepare(&before, items, tx_id, state.next_user)?;
317        let millis = i64::try_from(
318            SystemTime::now()
319                .duration_since(UNIX_EPOCH)
320                .map_err(|_| TransactError::Clock)?
321                .as_millis(),
322        )
323        .unwrap_or(i64::MAX);
324        let tx_instant = millis.max(state.last_instant.saturating_add(1));
325        self.log.append(&TxRecord {
326            t,
327            tx_instant,
328            datoms: prepared.datoms.clone(),
329        })?;
330        state.db = before.with_transaction(t, &prepared.datoms);
331        state.last_instant = tx_instant;
332        state.next_user = prepared
333            .tempids
334            .values()
335            .filter(|e| e.partition() == Partition::User as u32)
336            .map(|e| e.sequence() + 1)
337            .max()
338            .unwrap_or(state.next_user)
339            .max(state.next_user);
340        let report = TxReport {
341            db_before: before,
342            db_after: state.db.clone(),
343            tx: prepared,
344            tx_instant,
345        };
346        state
347            .subscribers
348            .retain(|subscriber| subscriber.send(report.clone()).is_ok());
349        Ok(report)
350    }
351
352    /// Validates under a short state lock, awaits durability without holding
353    /// that lock, then atomically publishes the durable transaction in memory.
354    /// Async calls are serialized here so standalone callers have the same
355    /// single-writer guarantee as node-hosted callers.
356    ///
357    /// # Errors
358    /// Returns an error for rejected transaction data, clock failure, or when
359    /// the durable append fails. No report is sent on error.
360    pub async fn transact_async(
361        &self,
362        items: impl IntoIterator<Item = TxItem>,
363    ) -> Result<TxReport, TransactError> {
364        let _commit = self.async_commit.lock().await;
365        let (before, prepared, t, tx_instant) = {
366            let mut state = self
367                .state
368                .lock()
369                .unwrap_or_else(std::sync::PoisonError::into_inner);
370            if state.async_pending {
371                return Err(TransactError::AsyncTransactionPending);
372            }
373            let before = state.db.clone();
374            let t = before.basis_t() + 1;
375            let tx_id = EntityId::new(Partition::Tx as u32, t);
376            let prepared = prepare(&before, items, tx_id, state.next_user)?;
377            let millis = i64::try_from(
378                SystemTime::now()
379                    .duration_since(UNIX_EPOCH)
380                    .map_err(|_| TransactError::Clock)?
381                    .as_millis(),
382            )
383            .unwrap_or(i64::MAX);
384            let tx_instant = millis.max(state.last_instant.saturating_add(1));
385            state.async_pending = true;
386            (before, prepared, t, tx_instant)
387        };
388        let record = TxRecord {
389            t,
390            tx_instant,
391            datoms: prepared.datoms.clone(),
392        };
393        let mut pending = AsyncPending {
394            state: &self.state,
395            active: true,
396        };
397        self.log.append_async(&record).await?;
398        let mut state = self
399            .state
400            .lock()
401            .unwrap_or_else(std::sync::PoisonError::into_inner);
402        debug_assert_eq!(state.db.basis_t() + 1, t);
403        // Apply to the live value so a naming-only update that ran while the
404        // append was pending is preserved; transaction-bearing state cannot
405        // change while `async_pending` is set.
406        state.db = state.db.clone().with_transaction(t, &prepared.datoms);
407        state.last_instant = tx_instant;
408        state.next_user = prepared
409            .tempids
410            .values()
411            .filter(|e| e.partition() == Partition::User as u32)
412            .map(|e| e.sequence() + 1)
413            .max()
414            .unwrap_or(state.next_user)
415            .max(state.next_user);
416        state.async_pending = false;
417        pending.active = false;
418        let report = TxReport {
419            db_before: before,
420            db_after: state.db.clone(),
421            tx: prepared,
422            tx_instant,
423        };
424        state
425            .subscribers
426            .retain(|subscriber| subscriber.send(report.clone()).is_ok());
427        Ok(report)
428    }
429    /// Replaces the ident/keyword naming attached to the current database
430    /// value (used when the boundary interns new keywords).
431    pub fn update_naming(&self, idents: Idents, interner: KeywordInterner) {
432        let mut state = self
433            .state
434            .lock()
435            .unwrap_or_else(std::sync::PoisonError::into_inner);
436        state.db = state.db.clone().with_naming(idents, interner);
437    }
438
439    /// Builds a consistent snapshot of all four indexes and publishes their blob ids.
440    ///
441    /// Each index is chunked into content-defined leaf blobs under a
442    /// manifest blob ([`corium_store::chunk_segment_keys`]), and only
443    /// chunks absent from the store are uploaded — consecutive publications
444    /// share every unchanged chunk, so a small change re-uploads a few
445    /// chunks instead of the whole index.
446    ///
447    /// Blobs are uploaded before the root CAS. Transactions may continue while the
448    /// immutable snapshot is encoded; a later run indexes any remaining log tail.
449    ///
450    /// Publication is fenced by `lease_version` and monotone in
451    /// `index_basis_t`: a root already published under a newer lease version
452    /// deposes this writer ([`TransactError::Deposed`]); a root at an equal
453    /// or newer basis (or one that wins a concurrent CAS race) leaves this
454    /// snapshot's blobs for garbage collection. The freshly built root is
455    /// returned when it, or a newer basis, is installed.
456    ///
457    /// # Errors
458    /// Returns an error if a blob upload, root read, or fenced publication fails.
459    pub async fn publish_indexes(
460        &self,
461        store: &(impl BlobStore + RootStore),
462        root_name: &str,
463        lease_version: u64,
464    ) -> Result<DbRoot, TransactError> {
465        let (snapshot, next_entity_id, last_tx_instant) = self.recovery_snapshot();
466        let datoms = snapshot.datoms();
467        let chunked = tokio::task::spawn_blocking(move || {
468            [
469                IndexOrder::Eavt,
470                IndexOrder::Aevt,
471                IndexOrder::Avet,
472                IndexOrder::Vaet,
473            ]
474            .into_iter()
475            .map(|order| {
476                let segment = Segment::build(order, datoms.clone());
477                corium_store::chunk_segment_keys(segment.entries().map(|(key, _)| key.as_slice()))
478            })
479            .collect::<Vec<_>>()
480        })
481        .await
482        .map_err(|error| TransactError::IndexTask(error.to_string()))?;
483        let mut ids = Vec::new();
484        for chunks in chunked {
485            let mut children = Vec::new();
486            for chunk in chunks {
487                children.push(store.put_if_absent(&chunk).await?);
488            }
489            let manifest = corium_store::encode_index_manifest(&children);
490            ids.push(store.put_if_absent(&manifest).await?);
491        }
492        let root = DbRoot {
493            format_version: corium_store::FORMAT_VERSION,
494            lease_version,
495            owner: String::new(),
496            lease_expires_unix_ms: 0,
497            owner_endpoint: String::new(),
498            index_basis_t: snapshot.basis_t(),
499            roots: Some([
500                ids[0].clone(),
501                ids[1].clone(),
502                ids[2].clone(),
503                ids[3].clone(),
504            ]),
505            // Recovery hints for opening from this root without full replay.
506            next_entity_id,
507            last_tx_instant,
508        };
509        publish_root(store, root_name, &root).await?;
510        Ok(root)
511    }
512}
513
514/// Publishes `root` under the fencing rules described on
515/// [`EmbeddedTransactor::publish_indexes`].
516///
517/// # Errors
518/// Returns [`TransactError::Deposed`] when a newer lease version owns the
519/// root, or a store error when the CAS cannot be completed.
520pub async fn publish_root(
521    store: &dyn RootStore,
522    root_name: &str,
523    root: &DbRoot,
524) -> Result<(), TransactError> {
525    loop {
526        let previous = store.get_root(root_name).await?;
527        let stored = previous.as_deref().and_then(DbRoot::decode);
528        let mut next = root.clone();
529        if let Some(stored) = stored {
530            if stored.lease_version > root.lease_version {
531                return Err(TransactError::Deposed {
532                    published: stored.lease_version,
533                });
534            }
535            if stored.lease_version == root.lease_version
536                && stored.index_basis_t >= root.index_basis_t
537            {
538                return Ok(());
539            }
540            // The stored record carries the live lease fields (renewals CAS
541            // the same key); publication must not clobber them.
542            if stored.lease_version == root.lease_version {
543                next.owner = stored.owner;
544                next.lease_expires_unix_ms = stored.lease_expires_unix_ms;
545                next.owner_endpoint = stored.owner_endpoint;
546            }
547        }
548        match store
549            .cas_root(root_name, previous.as_deref(), &next.encode())
550            .await
551        {
552            Ok(()) => return Ok(()),
553            Err(StoreError::CasFailed { .. }) => {}
554            Err(error) => return Err(error.into()),
555        }
556    }
557}
558
559pub use corium_store::{DbRoot, db_root_name};