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