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