use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use futures::FutureExt;
use slatedb::{Db, DbTransaction, IsolationLevel};
use tokio::sync::{Mutex, MutexGuard, watch};
use super::{
CommitDurability, HeadTransition, HeadViewUpdate, Landed, Prepared, StagedWrite, Submission,
await_submitted, fold, head_view_for, prepare_and_stage, submit_batch,
};
use crate::{
catalog::{CatalogSnapshot, SnapshotId, projection::ProjectionCache},
error::{Error, Result},
store::StagedBytes,
transaction::{operations::ChangeSet, verbs::Transaction},
};
const MAX_BATCH_MEMBERS: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Outcome {
Committed,
LostRace,
Abandoned,
Unknown(String),
Fenced(String),
}
#[derive(Clone)]
pub(crate) enum BatchState {
Forming,
Submitted,
Finished(Outcome),
}
pub(crate) struct Staged {
pub(crate) ids: Vec<SnapshotId>,
pub(crate) ours: Vec<ChangeSet>,
pub(crate) head_before: u64,
pub(crate) contributed: bool,
pub(crate) outcome: watch::Receiver<BatchState>,
}
struct Batch {
db_tx: DbTransaction,
base: Arc<CatalogSnapshot>,
premise: Option<CatalogSnapshot>,
folded: usize,
head_before: u64,
head: u64,
members: usize,
writes: Vec<StagedWrite>,
staged_bytes: StagedBytes,
outcome: watch::Sender<BatchState>,
}
impl Batch {
async fn open(db: &Db, projections: &std::sync::RwLock<ProjectionCache>) -> Result<Self> {
let db_tx = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
let base = match head_view_for(&db_tx, projections).await {
Ok(base) => base,
Err(err) => {
db_tx.rollback();
return Err(err);
}
};
let head_before = base.snapshot.snapshot_id;
Ok(Self {
db_tx,
base,
premise: None,
folded: 0,
head_before,
head: head_before,
members: 0,
writes: Vec::new(),
staged_bytes: StagedBytes::default(),
outcome: watch::Sender::new(BatchState::Forming),
})
}
fn refold(&mut self) -> Result<()> {
if self.folded == self.writes.len() {
return Ok(());
}
let mut premise = self.premise.take().unwrap_or_else(|| (*self.base).clone());
fold::fold_batch(&mut premise, &self.writes[self.folded..])?;
self.folded = self.writes.len();
self.premise = Some(premise);
Ok(())
}
async fn stage<F>(
&mut self,
members: &[F],
projections: &std::sync::RwLock<ProjectionCache>,
) -> Result<(Vec<SnapshotId>, Vec<ChangeSet>)>
where
F: Fn(&mut Transaction) -> Result<()>,
{
let mut ids = Vec::with_capacity(members.len());
let mut ours = Vec::new();
for member in members {
self.members += 1;
self.refold()?;
let premise = self.premise.as_ref().unwrap_or(&self.base);
let prepared =
prepare_and_stage(&self.db_tx, projections, member, premise, self.members == 1)
.await?;
match prepared {
Prepared::Nothing { head } => ids.push(SnapshotId::new(head)),
Prepared::Staged {
ours: theirs,
commits,
writes,
staged_bytes,
} => {
ids.push(SnapshotId::new(commits));
ours.push(*theirs);
self.head = commits;
self.writes.extend(writes);
self.staged_bytes.0 = self.staged_bytes.0.saturating_add(staged_bytes.0);
}
}
}
Ok((ids, ours))
}
}
struct Shared {
forming: Option<Batch>,
in_flight: bool,
}
pub(crate) struct Coalescer {
shared: Mutex<Shared>,
arriving: AtomicUsize,
flights: watch::Sender<u64>,
projections: Arc<std::sync::RwLock<ProjectionCache>>,
durability: CommitDurability,
}
impl Coalescer {
pub(crate) fn new(
projections: Arc<std::sync::RwLock<ProjectionCache>>,
durability: CommitDurability,
) -> Self {
Self {
shared: Mutex::new(Shared {
forming: None,
in_flight: false,
}),
arriving: AtomicUsize::new(0),
flights: watch::Sender::new(0),
projections,
durability,
}
}
fn arrive(self: &Arc<Self>) -> Arrival {
self.arriving.fetch_add(1, Ordering::AcqRel);
Arrival {
coalescer: Arc::clone(self),
settled: false,
}
}
async fn admit(&self) -> MutexGuard<'_, Shared> {
loop {
let shared = self.shared.lock().await;
if !shared.in_flight {
return shared;
}
let mut flights = self.flights.subscribe();
drop(shared);
let _ = flights.changed().await;
}
}
pub(crate) async fn stage<F>(self: &Arc<Self>, db: &Db, members: &[F]) -> Result<Staged>
where
F: Fn(&mut Transaction) -> Result<()>,
{
let arrival = self.arrive();
let mut shared = self.admit().await;
let mut batch = match shared.forming.take() {
Some(batch) => batch,
None => Batch::open(db, &self.projections).await?,
};
let staged_before = batch.writes.len();
let staged = match batch.stage(members, &self.projections).await {
Ok(staged) => staged,
Err(err) => {
batch.db_tx.rollback();
return Err(err);
}
};
let (ids, ours) = staged;
let outcome = batch.outcome.subscribe();
let head_before = batch.head_before;
let contributed = batch.writes.len() > staged_before;
let alone = arrival.settle() == 0;
let full = batch.members >= MAX_BATCH_MEMBERS;
if (alone || full) && !batch.writes.is_empty() {
shared.in_flight = true;
drop(shared);
self.launch(batch);
} else if batch.writes.is_empty() {
batch.db_tx.rollback();
} else {
shared.forming = Some(batch);
}
Ok(Staged {
ids,
ours,
head_before,
contributed,
outcome,
})
}
fn launch(self: &Arc<Self>, batch: Batch) {
let coalescer = Arc::clone(self);
drop(tokio::spawn(async move { coalescer.land(batch).await }));
}
async fn land(self: Arc<Self>, batch: Batch) {
let Batch {
db_tx,
base,
head_before,
head,
writes,
staged_bytes,
outcome,
..
} = batch;
outcome.send_replace(BatchState::Submitted);
let submission = std::panic::AssertUnwindSafe(submit_batch(
db_tx,
HeadTransition {
before: head_before,
after: head,
},
&writes,
staged_bytes,
HeadViewUpdate::Rebuild(base),
&self.projections,
))
.catch_unwind()
.await;
self.reopen().await;
let landed = match submission {
Ok(Ok(Submission::Submitted(submitted))) => {
match std::panic::AssertUnwindSafe(await_submitted(
submitted,
&self.projections,
&self.durability,
))
.catch_unwind()
.await
{
Ok(Ok(Landed::Committed(_))) => Outcome::Committed,
Ok(Ok(Landed::LostRace)) => Outcome::LostRace,
Ok(Err(Error::Fenced(reason))) => Outcome::Fenced(reason),
Ok(Err(err)) => {
tracing::warn!(error = %err, "commit batch outcome is unknown; its members must not retry");
Outcome::Unknown(err.to_string())
}
Err(_) => {
super::invalidate_head_view(&self.projections);
Outcome::Unknown(
"the submitted batch panicked before acknowledgement".into(),
)
}
}
}
Ok(Ok(Submission::LostRace)) => Outcome::LostRace,
Ok(Err(Error::Fenced(reason))) => Outcome::Fenced(reason),
Ok(Err(err)) => {
tracing::warn!(error = %err, "commit batch outcome is unknown; its members must not retry");
Outcome::Unknown(err.to_string())
}
Err(_) => {
super::invalidate_head_view(&self.projections);
Outcome::Unknown("the submitted batch panicked before acknowledgement".into())
}
};
let _ = outcome.send(BatchState::Finished(landed));
}
async fn reopen(&self) {
let mut shared = self.shared.lock().await;
shared.in_flight = false;
self.flights
.send_modify(|flight| *flight = flight.wrapping_add(1));
}
async fn seal_abandoned(self: Arc<Self>) {
let mut shared = self.shared.lock().await;
if shared.in_flight || self.arriving.load(Ordering::Acquire) > 0 {
return;
}
let Some(batch) = shared.forming.take() else {
return;
};
if batch.writes.is_empty() {
batch.db_tx.rollback();
return;
}
shared.in_flight = true;
drop(shared);
self.land(batch).await;
}
}
pub(crate) async fn await_outcome(mut outcome: watch::Receiver<BatchState>) -> Outcome {
loop {
if let BatchState::Finished(landed) = &*outcome.borrow_and_update() {
return landed.clone();
}
if outcome.changed().await.is_err() {
return match &*outcome.borrow() {
BatchState::Forming => Outcome::Abandoned,
BatchState::Submitted => {
Outcome::Unknown("the submitted batch stopped reporting".into())
}
BatchState::Finished(landed) => landed.clone(),
};
}
}
}
struct Arrival {
coalescer: Arc<Coalescer>,
settled: bool,
}
impl Arrival {
fn settle(mut self) -> usize {
self.settled = true;
self.coalescer.arriving.fetch_sub(1, Ordering::AcqRel) - 1
}
}
impl Drop for Arrival {
fn drop(&mut self) {
if self.settled {
return;
}
if self.coalescer.arriving.fetch_sub(1, Ordering::AcqRel) != 1 {
return;
}
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
let coalescer = Arc::clone(&self.coalescer);
drop(runtime.spawn(async move { coalescer.seal_abandoned().await }));
}
}
}
#[cfg(test)]
mod tests {
use object_store::memory::InMemory;
use super::*;
use crate::{Catalog, CatalogOptions};
#[tokio::test]
async fn a_lost_sender_preserves_the_submission_boundary() {
for (state, expected) in [
(BatchState::Forming, Outcome::Abandoned),
(
BatchState::Submitted,
Outcome::Unknown("the submitted batch stopped reporting".into()),
),
(BatchState::Finished(Outcome::Committed), Outcome::Committed),
] {
let (sender, receiver) = watch::channel(state);
drop(sender);
assert_eq!(await_outcome(receiver).await, expected);
}
}
#[tokio::test]
async fn a_write_error_is_not_reported_as_safe_to_retry() {
let catalog = Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap();
let store = catalog.store();
let db = store.writer_db().unwrap();
let mut batch = Batch::open(db, catalog.projections()).await.unwrap();
batch
.stage(
&[|tx: &mut Transaction| {
tx.create_schema("pending")?;
Ok(())
}],
catalog.projections(),
)
.await
.unwrap();
let outcome = batch.outcome.subscribe();
catalog.close().await.unwrap();
let coalescer = Arc::new(Coalescer::new(
Arc::clone(catalog.projections()),
CommitDurability::OnFlushInterval,
));
coalescer.land(batch).await;
assert!(matches!(await_outcome(outcome).await, Outcome::Unknown(_)));
}
}