use futures::future::BoxFuture;
use slatedb::{Db, DbTransaction, IsolationLevel};
use tracing::info;
mod inline_tombstones;
use crate::{
error::{Error, Result},
fault::{CrashPoint, crash_seam},
store::{
StagedBytes,
handle::ReadHandle,
key::{Key, SysKey},
proto, read, value,
},
transaction::commit::{self, CommitDurability, MAX_COMMIT_ATTEMPTS, commit_durable},
};
pub(crate) type StepOutcome = Option<StepProgress>;
pub(crate) struct StepProgress {
pub(crate) cursor: Vec<u8>,
pub(crate) staged: StagedBytes,
}
type StepFn = for<'a> fn(&'a DbTransaction, &'a [u8]) -> BoxFuture<'a, Result<StepOutcome>>;
pub(crate) struct MigrationUnit {
pub(crate) name: &'static str,
pub(crate) from_format: u64,
pub(crate) to_format: u64,
pub(crate) step: StepFn,
}
pub(crate) const MIGRATIONS: &[MigrationUnit] = &[inline_tombstones::MIGRATION];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct FormatRaise {
pub from_format: u64,
pub to_format: u64,
}
fn additive_ceiling(current: u64) -> u64 {
registry()
.into_iter()
.map(|unit| unit.from_format)
.filter(|from| *from >= current)
.min()
.map_or(commit::MAX_FORMAT_VERSION, |rewrite_at| {
rewrite_at.min(commit::MAX_FORMAT_VERSION)
})
}
pub(crate) async fn raise_format(db: &Db, dry_run: bool) -> Result<FormatRaise> {
let tx = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
let read = async {
let format = read::read_format(ReadHandle::Tx(&tx))
.await?
.map_or(commit::FORMAT_VERSION, |stamp| stamp.format_version);
let marker = read::read_migration(ReadHandle::Tx(&tx)).await?;
Ok::<_, Error>((format, marker))
}
.await;
let (from_format, marker) = match read {
Ok(read) => read,
Err(error) => {
tx.rollback();
return Err(error);
}
};
if let Some(marker) = marker {
tx.rollback();
return Err(Error::Migration(format!(
"store is mid-migration from format {} to {}; finish that before raising the format",
marker.from_format, marker.to_format
)));
}
let to_format = additive_ceiling(from_format);
if dry_run {
tx.rollback();
return Ok(FormatRaise {
from_format,
to_format: to_format.max(from_format),
});
}
if to_format <= from_format {
tx.rollback();
return Ok(FormatRaise {
from_format,
to_format: from_format,
});
}
let key = Key::Sys(SysKey::Format).encode();
let value = value::encode_value(&proto::FormatValue {
format_version: to_format,
writer_version: env!("CARGO_PKG_VERSION").to_string(),
});
let mut staged = StagedBytes::default();
staged.add(key.len(), value.len());
if let Err(error) = tx.put(key, value) {
tx.rollback();
return Err(Error::from(error));
}
commit_durable(
tx,
"format raise",
staged,
&CommitDurability::OnFlushInterval,
)
.await?;
info!(from_format, to_format, "raised the store format");
Ok(FormatRaise {
from_format,
to_format,
})
}
fn registry() -> Vec<&'static MigrationUnit> {
MIGRATIONS
.iter()
.chain(crate::fault::installed_migrations().iter().copied())
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MigrationReport {
pub from_format: u64,
pub to_format: u64,
pub units_run: Vec<String>,
pub resumed: bool,
}
struct Plan {
units: Vec<&'static MigrationUnit>,
resume: Option<Vec<u8>>,
}
fn chain_from(format: u64) -> Vec<&'static MigrationUnit> {
let registry = registry();
let mut units = Vec::new();
let mut format = format;
while let Some(unit) = registry.iter().find(|unit| unit.from_format == format) {
units.push(*unit);
format = unit.to_format;
}
units
}
fn plan(format: u64, marker: Option<&proto::MigrationValue>) -> Result<Plan> {
let Some(marker) = marker else {
return Ok(Plan {
units: chain_from(format),
resume: None,
});
};
if marker.from_format != format {
return Err(Error::Corruption(format!(
"store is stamped format {format} but carries a migration marker from format {} \
to {}; the flip and the clear land in one batch, so this pairing cannot arise",
marker.from_format, marker.to_format
)));
}
let interrupted = registry()
.into_iter()
.find(|unit| unit.from_format == marker.from_format && unit.to_format == marker.to_format)
.ok_or_else(|| {
Error::Migration(format!(
"store is mid-migration from format {} to {}, which this binary does not carry; \
finish it with the binary that started it",
marker.from_format, marker.to_format
))
})?;
let mut units = vec![interrupted];
units.extend(chain_from(interrupted.to_format));
Ok(Plan {
units,
resume: Some(marker.cursor.clone()),
})
}
fn stage_marker(tx: &DbTransaction, unit: &MigrationUnit, cursor: &[u8]) -> Result<StagedBytes> {
let key = Key::Sys(SysKey::Migration).encode();
let value = value::encode_value(&proto::MigrationValue {
from_format: unit.from_format,
to_format: unit.to_format,
cursor: cursor.to_vec(),
});
let mut staged = StagedBytes::default();
staged.add(key.len(), value.len());
tx.put(key, value).map_err(Error::from)?;
Ok(staged)
}
async fn start(db: &Db, unit: &MigrationUnit) -> Result<()> {
for attempt in 0..MAX_COMMIT_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(commit::retry_backoff(attempt)).await;
}
let tx = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
let staged = match stage_start(&tx, unit).await {
Ok(staged) => staged,
Err(error) => {
tx.rollback();
return Err(error);
}
};
match commit_durable(
tx,
"migration start",
staged,
&CommitDurability::OnFlushInterval,
)
.await
{
Ok(_) => return Ok(()),
Err(error) if error.kind() == slatedb::ErrorKind::Transaction => {
info!(attempt, "migration start lost the head race; retrying");
}
Err(error) => return Err(Error::from(error)),
}
}
Err(Error::RetryBudgetExhausted(format!(
"migration start from format {} to {} spent {MAX_COMMIT_ATTEMPTS} attempts without \
settling; commits are landing faster than it can claim the head",
unit.from_format, unit.to_format
)))
}
async fn stage_start(tx: &DbTransaction, unit: &MigrationUnit) -> Result<StagedBytes> {
let mut staged = stage_marker(tx, unit, &[])?;
if let Some(head) = read::read_head(ReadHandle::Tx(tx)).await? {
let (key, value) = commit::head_stamp(head.snapshot_id, head.batch_seq);
let value = value.unwrap_or_default();
staged.add(key.len(), value.len());
tx.put(key, value).map_err(Error::from)?;
}
Ok(staged)
}
async fn finish(db: &Db, unit: &MigrationUnit) -> Result<()> {
let tx = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
let format = Key::Sys(SysKey::Format).encode();
let stamp = value::encode_value(&proto::FormatValue {
format_version: unit.to_format,
writer_version: env!("CARGO_PKG_VERSION").to_string(),
});
let marker = Key::Sys(SysKey::Migration).encode();
let mut staged = StagedBytes::default();
staged.add(format.len(), stamp.len());
staged.add(marker.len(), 0);
if let Err(error) = tx.put(format, stamp).and_then(|()| tx.delete(marker)) {
tx.rollback();
return Err(Error::from(error));
}
commit_durable(
tx,
"migration finish",
staged,
&CommitDurability::OnFlushInterval,
)
.await
.map_err(Error::from)?;
Ok(())
}
async fn run_unit(db: &Db, unit: &MigrationUnit, resume: Option<Vec<u8>>) -> Result<()> {
let mut cursor = if let Some(cursor) = resume {
cursor
} else {
start(db, unit).await?;
crash_seam(CrashPoint::AfterStart)?;
Vec::new()
};
loop {
let tx = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
let outcome = match (unit.step)(&tx, &cursor).await {
Ok(outcome) => outcome,
Err(error) => {
tx.rollback();
return Err(error);
}
};
let Some(StepProgress {
cursor: next,
staged: rewritten,
}) = outcome
else {
tx.rollback();
break;
};
let mut staged = match stage_marker(&tx, unit, &next) {
Ok(staged) => staged,
Err(error) => {
tx.rollback();
return Err(error);
}
};
staged.0 = staged.0.saturating_add(rewritten.0);
commit_durable(
tx,
"migration step",
staged,
&CommitDurability::OnFlushInterval,
)
.await
.map_err(Error::from)?;
crash_seam(CrashPoint::AfterStep)?;
cursor = next;
}
crash_seam(CrashPoint::BeforeFinish)?;
finish(db, unit).await?;
crash_seam(CrashPoint::AfterFinish)?;
Ok(())
}
pub(crate) async fn run(db: &Db) -> Result<MigrationReport> {
let tx = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
let (format, marker) = futures::join!(
read::read_format(ReadHandle::Tx(&tx)),
read::read_migration(ReadHandle::Tx(&tx)),
);
tx.rollback();
let format = format?.ok_or_else(|| {
Error::Corruption(
"store is not an initialized moraine catalog; there is nothing to migrate".to_string(),
)
})?;
let from_format = format.format_version;
let marker = marker?;
let planning_format = if marker.is_none() && from_format < commit::MIN_FORMAT_VERSION {
raise_format(db, false).await?.to_format
} else {
from_format
};
let Plan { units, mut resume } = plan(planning_format, marker.as_ref())?;
let resumed = resume.is_some();
if units.is_empty() {
return Ok(MigrationReport {
from_format,
to_format: planning_format,
units_run: Vec::new(),
resumed,
});
}
let mut units_run = Vec::with_capacity(units.len());
let mut to_format = planning_format;
for unit in units {
info!(
unit = unit.name,
from = unit.from_format,
to = unit.to_format,
resuming = resume.is_some(),
"migrating the store format"
);
run_unit(db, unit, resume.take()).await?;
units_run.push(unit.name.to_string());
to_format = unit.to_format;
}
info!(from = from_format, to = to_format, "migration complete");
Ok(MigrationReport {
from_format,
to_format,
units_run,
resumed,
})
}
#[cfg(any(test, feature = "fault-injection"))]
pub(crate) mod synthetic;
#[cfg(test)]
mod tests;