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