use std::sync::{
Arc,
atomic::{AtomicBool, 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,
telemetry::{STALL_INTERVAL, reporting_phase},
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).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>,
}
pub(crate) struct Coalescer {
shared: Mutex<Shared>,
arriving: AtomicUsize,
in_flight: AtomicBool,
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 }),
arriving: AtomicUsize::new(0),
in_flight: AtomicBool::new(false),
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 mut flights = self.flights.subscribe();
let shared = self.shared.lock().await;
if !self.in_flight.load(Ordering::Acquire) {
return shared;
}
drop(shared);
let _ = flights.changed().await;
}
}
fn enter_flight(self: &Arc<Self>) -> Flight {
self.in_flight.store(true, Ordering::Release);
Flight(Arc::clone(self))
}
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 = reporting_phase("admit", self.admit()).await;
let mut batch = match shared.forming.take() {
Some(batch) => batch,
None => reporting_phase("open", Batch::open(db, &self.projections)).await?,
};
let staged_before = batch.writes.len();
let staged = match reporting_phase("stage", 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() {
let flight = self.enter_flight();
drop(shared);
self.launch(batch, flight);
} 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, flight: Flight) {
let coalescer = Arc::clone(self);
drop(tokio::spawn(
async move { coalescer.land(batch, flight).await },
));
}
async fn land(self: Arc<Self>, batch: Batch, flight: Flight) {
let Batch {
db_tx,
base,
head_before,
head,
writes,
staged_bytes,
outcome,
..
} = batch;
outcome.send_replace(BatchState::Submitted);
let submission = std::panic::AssertUnwindSafe(reporting_phase(
"submit",
submit_batch(
db_tx,
HeadTransition {
before: head_before,
after: head,
},
&writes,
staged_bytes,
HeadViewUpdate::Rebuild(base),
&self.projections,
),
))
.catch_unwind()
.await;
drop(flight);
let landed = match submission {
Ok(Ok(Submission::Submitted(submitted))) => {
match std::panic::AssertUnwindSafe(reporting_phase(
"durable",
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 seal_abandoned(self: Arc<Self>) {
let mut shared = self.shared.lock().await;
if self.in_flight.load(Ordering::Acquire) || 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;
}
let flight = self.enter_flight();
drop(shared);
self.land(batch, flight).await;
}
}
struct Flight(Arc<Coalescer>);
impl Drop for Flight {
fn drop(&mut self) {
self.0.in_flight.store(false, Ordering::Release);
self.0
.flights
.send_modify(|flight| *flight = flight.wrapping_add(1));
}
}
pub(crate) async fn await_outcome_sealing(
coalescer: &Arc<Coalescer>,
mut outcome: watch::Receiver<BatchState>,
) -> Outcome {
loop {
if let BatchState::Finished(landed) = &*outcome.borrow_and_update() {
return landed.clone();
}
match tokio::time::timeout(STALL_INTERVAL, outcome.changed()).await {
Ok(Ok(())) => {}
Ok(Err(_)) => return finished_or_boundary(&outcome),
Err(_) => Arc::clone(coalescer).seal_abandoned().await,
}
}
}
fn finished_or_boundary(outcome: &watch::Receiver<BatchState>) -> Outcome {
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!(finished_or_boundary(&receiver), 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()),
catalog.store().commit_durability(),
));
let flight = coalescer.enter_flight();
Arc::clone(&coalescer).land(batch, flight).await;
assert!(matches!(
await_outcome_sealing(&coalescer, outcome).await,
Outcome::Unknown(_)
));
}
#[tokio::test(start_paused = true)]
async fn a_waiter_seals_the_batch_no_one_else_will() {
let catalog = Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap();
let store = catalog.store();
let db = store.writer_db().unwrap();
let coalescer = Arc::new(Coalescer::new(
Arc::clone(catalog.projections()),
store.commit_durability(),
));
let mut batch = Batch::open(db, catalog.projections()).await.unwrap();
batch
.stage(
&[|tx: &mut Transaction| {
tx.create_schema("stranded")?;
Ok(())
}],
catalog.projections(),
)
.await
.unwrap();
let outcome = batch.outcome.subscribe();
coalescer.shared.lock().await.forming = Some(batch);
assert_eq!(coalescer.arriving.load(Ordering::Acquire), 0);
assert!(!coalescer.in_flight.load(Ordering::Acquire));
let landed = await_outcome_sealing(&coalescer, outcome).await;
assert!(
matches!(landed, Outcome::Committed),
"the waiter must seal and land the batch it is riding, got {landed:?}"
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_dropped_flight_admits_the_next_batch() {
let catalog = Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap();
let coalescer = Arc::new(Coalescer::new(
Arc::clone(catalog.projections()),
catalog.store().commit_durability(),
));
let flight = coalescer.enter_flight();
assert!(coalescer.in_flight.load(Ordering::Acquire));
drop(flight);
let admitted = tokio::time::timeout(std::time::Duration::from_secs(5), coalescer.admit())
.await
.is_ok();
assert!(admitted, "a dropped flight must not latch the store closed");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_flight_ending_during_admission_still_wakes_the_waiter() {
let catalog = Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap();
let coalescer = Arc::new(Coalescer::new(
Arc::clone(catalog.projections()),
catalog.store().commit_durability(),
));
let flight = coalescer.enter_flight();
let waiting = tokio::spawn({
let coalescer = Arc::clone(&coalescer);
async move {
drop(coalescer.admit().await);
}
});
tokio::task::yield_now().await;
drop(flight);
tokio::time::timeout(std::time::Duration::from_secs(5), waiting)
.await
.expect("the waiter must observe the flight ending")
.unwrap();
catalog.close().await.unwrap();
}
}