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