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