pub mod authz;
pub mod backend;
pub mod backup;
pub mod lease;
pub mod metrics;
pub mod node;
pub mod server;
#[cfg(feature = "cljrs")]
pub mod txfn;
pub use backend::{LogBackend, NodeStore, StorageConnectionError, StorageInfoConfig, StoreSpec};
#[cfg(feature = "s3")]
pub use backend::{S3ReadOnlyConfig, S3ReadOnlyCredentials};
use corium_core::{EntityId, IndexOrder, KeywordInterner, Partition, Schema};
use corium_db::{Db, FIRST_USER_ID, Idents};
use corium_index::{Leaf, LeafId, Segment};
use corium_log::{LogError, TransactionLog, TxRecord};
use corium_store::{BlobId, BlobStore, RootStore, StoreError};
use corium_tx::{PreparedTx, TxError, TxItem, prepare};
use std::{
collections::HashMap,
sync::{Arc, Mutex, mpsc},
time::{SystemTime, UNIX_EPOCH},
};
use thiserror::Error;
const ORDERS: [IndexOrder; 4] = [
IndexOrder::Eavt,
IndexOrder::Aevt,
IndexOrder::Avet,
IndexOrder::Vaet,
];
#[derive(Clone, Debug)]
pub struct TxReport {
pub db_before: Db,
pub db_after: Db,
pub tx: PreparedTx,
pub tx_instant: i64,
}
#[derive(Debug, Error)]
pub enum TransactError {
#[error(transparent)]
Tx(#[from] TxError),
#[error(transparent)]
Log(#[from] LogError),
#[error(transparent)]
Store(#[from] StoreError),
#[error("system clock is before Unix epoch")]
Clock,
#[error("index task failed: {0}")]
IndexTask(String),
#[error("an asynchronous transaction is already in progress")]
AsyncTransactionPending,
#[error("deposed: database root is owned by lease version {published}")]
Deposed {
published: u64,
},
}
fn seal_tx_instant(
datoms: &mut Vec<corium_core::Datom>,
t: u64,
now_ms: i64,
last_instant: i64,
) -> Result<i64, TxError> {
match corium_db::bootstrap::asserted_instant(t, datoms) {
Some(supplied) if supplied <= last_instant => Err(TxError::TxInstantNotMonotonic {
supplied,
last: last_instant,
}),
Some(supplied) => Ok(supplied),
None => {
let instant = now_ms.max(last_instant.saturating_add(1));
datoms.push(corium_db::bootstrap::tx_instant_datom(t, instant));
Ok(instant)
}
}
}
fn effective_tx_instant(record: &TxRecord) -> i64 {
corium_db::bootstrap::asserted_instant(record.t, &record.datoms).unwrap_or(record.tx_instant)
}
struct State {
db: Db,
next_user: u64,
last_instant: i64,
subscribers: Vec<mpsc::Sender<TxReport>>,
async_pending: bool,
}
struct AsyncPending<'a> {
state: &'a Mutex<State>,
active: bool,
}
impl Drop for AsyncPending<'_> {
fn drop(&mut self) {
if self.active {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.async_pending = false;
}
}
}
fn next_user_id<'a>(datoms: impl Iterator<Item = &'a corium_core::Datom>, floor: u64) -> u64 {
datoms
.filter(|d| d.e.partition() == Partition::User as u32)
.map(|d| d.e.sequence() + 1)
.fold(floor, u64::max)
}
pub struct Prepared {
pub record: TxRecord,
db_before: Db,
db_after: Db,
tx: PreparedTx,
tx_instant: i64,
}
pub struct BatchCursor {
db: Db,
next_user: u64,
last_instant: i64,
}
impl BatchCursor {
#[must_use]
pub fn db(&self) -> &Db {
&self.db
}
pub fn prepare(
&mut self,
items: impl IntoIterator<Item = TxItem>,
now_ms: i64,
) -> Result<Prepared, TxError> {
let before = self.db.clone();
let t = before.basis_t() + 1;
let tx_id = EntityId::new(Partition::Tx as u32, t);
let mut prepared = prepare(&before, items, tx_id, self.next_user)?;
let tx_instant = seal_tx_instant(&mut prepared.datoms, t, now_ms, self.last_instant)?;
let after = before
.clone()
.with_transaction_at(t, tx_instant, &prepared.datoms);
self.next_user = prepared
.tempids
.values()
.filter(|e| e.partition() == Partition::User as u32)
.map(|e| e.sequence() + 1)
.max()
.unwrap_or(self.next_user)
.max(self.next_user);
self.last_instant = tx_instant;
self.db = after.clone();
Ok(Prepared {
record: TxRecord {
t,
tx_instant,
datoms: prepared.datoms.clone(),
},
db_before: before,
db_after: after,
tx: prepared,
tx_instant,
})
}
}
struct PublishedIndexes {
lease_version: u64,
basis_t: u64,
segments: [Segment; 4],
manifests: [BlobId; 4],
chunks: [HashMap<LeafId, BlobId>; 4],
}
impl PublishedIndexes {
fn is_current(&self, root: Option<&DbRoot>, lease_version: u64) -> bool {
root.is_some_and(|root| {
self.lease_version == lease_version
&& root.lease_version == lease_version
&& root.index_basis_t == self.basis_t
&& root.roots.as_ref() == Some(&self.manifests)
})
}
}
enum ChunkPlan {
Published(BlobId),
Rebuilt(Vec<u8>),
}
pub struct EmbeddedTransactor {
log: Arc<dyn TransactionLog>,
state: Mutex<State>,
async_commit: tokio::sync::Mutex<()>,
published: Mutex<Option<PublishedIndexes>>,
}
impl EmbeddedTransactor {
pub fn recover(schema: Schema, log: Arc<dyn TransactionLog>) -> Result<Self, TransactError> {
Self::recover_from(Db::new(schema), log)
}
pub fn recover_from(base: Db, log: Arc<dyn TransactionLog>) -> Result<Self, TransactError> {
let mut db = base;
let mut last_instant = i64::MIN;
for record in log.replay()? {
let tx_instant = effective_tx_instant(&record);
db = db.with_transaction_at(record.t, record.tx_instant, &record.datoms);
last_instant = last_instant.max(tx_instant);
}
let next_user = next_user_id(db.recorded_datoms(), FIRST_USER_ID);
Ok(Self {
log,
state: Mutex::new(State {
db,
next_user,
last_instant,
subscribers: Vec::new(),
async_pending: false,
}),
async_commit: tokio::sync::Mutex::new(()),
published: Mutex::new(None),
})
}
pub async fn recover_from_async(
base: Db,
log: Arc<dyn TransactionLog>,
) -> Result<Self, TransactError> {
let records = log.replay_async().await?;
Ok(Self::recover_from_records(base, log, records))
}
fn recover_from_records(
mut db: Db,
log: Arc<dyn TransactionLog>,
records: Vec<TxRecord>,
) -> Self {
let mut last_instant = i64::MIN;
for record in records {
let tx_instant = effective_tx_instant(&record);
db = db.with_transaction_at(record.t, record.tx_instant, &record.datoms);
last_instant = last_instant.max(tx_instant);
}
let next_user = next_user_id(db.recorded_datoms(), FIRST_USER_ID);
Self {
log,
state: Mutex::new(State {
db,
next_user,
last_instant,
subscribers: Vec::new(),
async_pending: false,
}),
async_commit: tokio::sync::Mutex::new(()),
published: Mutex::new(None),
}
}
pub fn recover_from_snapshot(
snapshot: Db,
next_entity_id: u64,
last_tx_instant: i64,
log: Arc<dyn TransactionLog>,
) -> Result<Self, TransactError> {
let mut db = snapshot;
let index_basis = db.basis_t();
let mut last_instant = last_tx_instant;
let mut next_user = next_entity_id.max(FIRST_USER_ID);
for record in log.tx_range(index_basis + 1, None)? {
let tx_instant = effective_tx_instant(&record);
db = db.with_transaction_at(record.t, record.tx_instant, &record.datoms);
last_instant = last_instant.max(tx_instant);
next_user = next_user.max(next_user_id(record.datoms.iter(), next_user));
}
Ok(Self {
log,
state: Mutex::new(State {
db,
next_user,
last_instant,
subscribers: Vec::new(),
async_pending: false,
}),
async_commit: tokio::sync::Mutex::new(()),
published: Mutex::new(None),
})
}
pub async fn recover_from_snapshot_async(
snapshot: Db,
next_entity_id: u64,
last_tx_instant: i64,
log: Arc<dyn TransactionLog>,
) -> Result<Self, TransactError> {
let index_basis = snapshot.basis_t();
let records = log.tx_range_async(index_basis + 1, None).await?;
let mut db = snapshot;
let mut last_instant = last_tx_instant;
let mut next_user = next_entity_id.max(FIRST_USER_ID);
for record in records {
let tx_instant = effective_tx_instant(&record);
db = db.with_transaction_at(record.t, record.tx_instant, &record.datoms);
last_instant = last_instant.max(tx_instant);
next_user = next_user.max(next_user_id(record.datoms.iter(), next_user));
}
Ok(Self {
log,
state: Mutex::new(State {
db,
next_user,
last_instant,
subscribers: Vec::new(),
async_pending: false,
}),
async_commit: tokio::sync::Mutex::new(()),
published: Mutex::new(None),
})
}
fn recovery_snapshot(&self) -> (Db, u64, i64) {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(state.db.clone(), state.next_user, state.last_instant)
}
#[must_use]
pub fn db(&self) -> Db {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.db
.clone()
}
pub fn subscribe(&self) -> mpsc::Receiver<TxReport> {
let (tx, rx) = mpsc::channel();
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.subscribers
.push(tx);
rx
}
pub fn transact(
&self,
items: impl IntoIterator<Item = TxItem>,
) -> Result<TxReport, TransactError> {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.async_pending {
return Err(TransactError::AsyncTransactionPending);
}
let before = state.db.clone();
let t = before.basis_t() + 1;
let tx_id = EntityId::new(Partition::Tx as u32, t);
let mut prepared = prepare(&before, items, tx_id, state.next_user)?;
let millis = i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| TransactError::Clock)?
.as_millis(),
)
.unwrap_or(i64::MAX);
let tx_instant = seal_tx_instant(&mut prepared.datoms, t, millis, state.last_instant)?;
self.log.append(&TxRecord {
t,
tx_instant,
datoms: prepared.datoms.clone(),
})?;
state.db = before.with_transaction_at(t, tx_instant, &prepared.datoms);
state.last_instant = tx_instant;
state.next_user = prepared
.tempids
.values()
.filter(|e| e.partition() == Partition::User as u32)
.map(|e| e.sequence() + 1)
.max()
.unwrap_or(state.next_user)
.max(state.next_user);
let report = TxReport {
db_before: before,
db_after: state.db.clone(),
tx: prepared,
tx_instant,
};
state
.subscribers
.retain(|subscriber| subscriber.send(report.clone()).is_ok());
Ok(report)
}
pub async fn transact_async(
&self,
items: impl IntoIterator<Item = TxItem>,
) -> Result<TxReport, TransactError> {
let _commit = self.async_commit.lock().await;
let (before, prepared, t, tx_instant) = {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.async_pending {
return Err(TransactError::AsyncTransactionPending);
}
let before = state.db.clone();
let t = before.basis_t() + 1;
let tx_id = EntityId::new(Partition::Tx as u32, t);
let mut prepared = prepare(&before, items, tx_id, state.next_user)?;
let millis = i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| TransactError::Clock)?
.as_millis(),
)
.unwrap_or(i64::MAX);
let tx_instant = seal_tx_instant(&mut prepared.datoms, t, millis, state.last_instant)?;
state.async_pending = true;
(before, prepared, t, tx_instant)
};
let record = TxRecord {
t,
tx_instant,
datoms: prepared.datoms.clone(),
};
let mut pending = AsyncPending {
state: &self.state,
active: true,
};
self.log.append_async(&record).await?;
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
debug_assert_eq!(state.db.basis_t() + 1, t);
state.db = state
.db
.clone()
.with_transaction_at(t, tx_instant, &prepared.datoms);
state.last_instant = tx_instant;
state.next_user = prepared
.tempids
.values()
.filter(|e| e.partition() == Partition::User as u32)
.map(|e| e.sequence() + 1)
.max()
.unwrap_or(state.next_user)
.max(state.next_user);
state.async_pending = false;
pending.active = false;
let report = TxReport {
db_before: before,
db_after: state.db.clone(),
tx: prepared,
tx_instant,
};
state
.subscribers
.retain(|subscriber| subscriber.send(report.clone()).is_ok());
Ok(report)
}
#[must_use]
pub fn batch_cursor(&self) -> BatchCursor {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
BatchCursor {
db: state.db.clone(),
next_user: state.next_user,
last_instant: state.last_instant,
}
}
pub fn install_batch(&self, cursor: BatchCursor, prepared: Vec<Prepared>) -> Vec<TxReport> {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.db = cursor.db;
state.next_user = state.next_user.max(cursor.next_user);
state.last_instant = state.last_instant.max(cursor.last_instant);
let reports: Vec<TxReport> = prepared
.into_iter()
.map(|p| TxReport {
db_before: p.db_before,
db_after: p.db_after,
tx: p.tx,
tx_instant: p.tx_instant,
})
.collect();
for report in &reports {
state
.subscribers
.retain(|subscriber| subscriber.send(report.clone()).is_ok());
}
reports
}
pub fn update_naming(&self, idents: Idents, interner: KeywordInterner) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.db = state.db.clone().with_naming(idents, interner);
}
pub async fn publish_indexes(
&self,
store: &(impl BlobStore + RootStore),
root_name: &str,
lease_version: u64,
) -> Result<DbRoot, TransactError> {
let previous = self
.published
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
if let PassOutcome::Published(root) = self
.publish_pass(store, root_name, lease_version, previous)
.await?
{
return Ok(root);
}
match self
.publish_pass(store, root_name, lease_version, None)
.await?
{
PassOutcome::Published(root) => Ok(root),
PassOutcome::Raced => {
unreachable!("a pass that carries nothing over publishes unconditionally")
}
}
}
async fn publish_pass(
&self,
store: &(impl BlobStore + RootStore),
root_name: &str,
lease_version: u64,
previous: Option<PublishedIndexes>,
) -> Result<PassOutcome, TransactError> {
let stored = store
.get_root(root_name)
.await?
.as_deref()
.and_then(DbRoot::decode);
if let Some(published) = stored.as_ref().map(|root| root.lease_version)
&& published > lease_version
{
return Err(TransactError::Deposed { published });
}
let (snapshot, next_entity_id, last_tx_instant) = self.recovery_snapshot();
let basis_t = snapshot.basis_t();
let previous = previous.filter(|previous| {
previous.is_current(stored.as_ref(), lease_version) && previous.basis_t <= basis_t
});
let pinned = previous
.as_ref()
.map(|_| RootIndexState::of(stored.as_ref()));
let (segments, planned) =
tokio::task::spawn_blocking(move || plan_indexes(&snapshot, previous.as_ref()))
.await
.map_err(|error| TransactError::IndexTask(error.to_string()))?;
let mut manifests = Vec::with_capacity(ORDERS.len());
let mut chunk_ids = Vec::with_capacity(ORDERS.len());
for chunks in &planned {
let mut children = Vec::with_capacity(chunks.len());
for chunk in chunks {
children.push(match chunk {
ChunkPlan::Published(id) => id.clone(),
ChunkPlan::Rebuilt(bytes) => store.put_if_absent(bytes).await?,
});
}
let manifest = corium_store::encode_index_manifest(&children);
manifests.push(store.put_if_absent(&manifest).await?);
chunk_ids.push(children);
}
let manifests: [BlobId; 4] = manifests
.try_into()
.unwrap_or_else(|_| unreachable!("one manifest per covering index"));
let root = DbRoot {
format_version: corium_store::FORMAT_VERSION,
lease_version,
owner: String::new(),
lease_expires_unix_ms: 0,
owner_endpoint: String::new(),
index_basis_t: basis_t,
roots: Some(manifests.clone()),
next_entity_id,
last_tx_instant,
};
let outcome = publish_root_pinned(store, root_name, &root, pinned.as_ref()).await?;
if outcome == RootPublication::Raced {
return Ok(PassOutcome::Raced);
}
let next = PublishedIndexes {
lease_version,
basis_t,
chunks: chunk_ids_by_leaf(&segments, chunk_ids),
segments,
manifests,
};
if outcome == RootPublication::Installed || next.is_current(stored.as_ref(), lease_version)
{
*self
.published
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(next);
}
Ok(PassOutcome::Published(root))
}
}
enum PassOutcome {
Published(DbRoot),
Raced,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RootIndexState {
index_basis_t: u64,
roots: Option<[BlobId; 4]>,
}
impl RootIndexState {
fn of(root: Option<&DbRoot>) -> Self {
Self {
index_basis_t: root.map_or(0, |root| root.index_basis_t),
roots: root.and_then(|root| root.roots.clone()),
}
}
}
fn plan_indexes(
snapshot: &Db,
previous: Option<&PublishedIndexes>,
) -> ([Segment; 4], [Vec<ChunkPlan>; 4]) {
let mut segments: Vec<Segment> = Vec::with_capacity(ORDERS.len());
let mut planned: Vec<Vec<ChunkPlan>> = Vec::with_capacity(ORDERS.len());
for (slot, order) in ORDERS.into_iter().enumerate() {
let segment = match previous {
Some(previous) => previous.segments[slot].apply_ref(
order,
snapshot
.recorded_since(previous.basis_t)
.filter(|datom| corium_db::covered(snapshot.schema(), order, datom)),
),
None => Segment::from_sorted_ref(order, snapshot.datoms_at(order)),
};
let stored = previous.map(|previous| &previous.chunks[slot]);
planned.push(
segment
.leaves()
.map(|leaf| plan_chunk(leaf, stored))
.collect(),
);
segments.push(segment);
}
(
segments
.try_into()
.unwrap_or_else(|_| unreachable!("one segment per covering index")),
planned
.try_into()
.unwrap_or_else(|_| unreachable!("one chunk plan per covering index")),
)
}
fn plan_chunk(leaf: &Leaf, stored: Option<&HashMap<LeafId, BlobId>>) -> ChunkPlan {
match stored.and_then(|stored| stored.get(&leaf.id())) {
Some(id) => ChunkPlan::Published(id.clone()),
None => ChunkPlan::Rebuilt(corium_store::encode_segment_chunk(
leaf.keys().iter().map(Vec::as_slice),
)),
}
}
fn chunk_ids_by_leaf(
segments: &[Segment; 4],
chunk_ids: Vec<Vec<BlobId>>,
) -> [HashMap<LeafId, BlobId>; 4] {
let mut per_index = chunk_ids.into_iter();
std::array::from_fn(|slot| {
segments[slot]
.leaves()
.map(Leaf::id)
.zip(per_index.next().unwrap_or_default())
.collect()
})
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RootPublication {
Installed,
Superseded,
Raced,
}
pub async fn publish_root(
store: &dyn RootStore,
root_name: &str,
root: &DbRoot,
) -> Result<RootPublication, TransactError> {
publish_root_pinned(store, root_name, root, None).await
}
async fn publish_root_pinned(
store: &dyn RootStore,
root_name: &str,
root: &DbRoot,
pinned: Option<&RootIndexState>,
) -> Result<RootPublication, TransactError> {
loop {
let previous = store.get_root(root_name).await?;
let stored = previous.as_deref().and_then(DbRoot::decode);
if let Some(pinned) = pinned
&& RootIndexState::of(stored.as_ref()) != *pinned
{
return Ok(RootPublication::Raced);
}
let mut next = root.clone();
if let Some(stored) = stored {
if stored.lease_version > root.lease_version {
return Err(TransactError::Deposed {
published: stored.lease_version,
});
}
if stored.lease_version == root.lease_version
&& stored.index_basis_t >= root.index_basis_t
{
return Ok(RootPublication::Superseded);
}
if stored.lease_version == root.lease_version {
next.owner = stored.owner;
next.lease_expires_unix_ms = stored.lease_expires_unix_ms;
next.owner_endpoint = stored.owner_endpoint;
}
}
match store
.cas_root(root_name, previous.as_deref(), &next.encode())
.await
{
Ok(()) => return Ok(RootPublication::Installed),
Err(StoreError::CasFailed { .. }) => {}
Err(error) => return Err(error.into()),
}
}
}
pub use corium_store::{DbRoot, db_root_name};