#[cfg(any(test, feature = "migration"))]
use crate::db::schema::preflight_schema_migration_record_op;
use crate::{
db::{
commit::{
BACKLOG_LIMITS, BacklogAdmission, ExactBacklogMeasurement, admit_backlog,
current_database_backlog,
marker::{CommitMarker, DatabaseControlOp},
store::{EncodedCommitControlSlot, with_commit_store, with_initialized_commit_store},
},
integrity::preflight_mutation_progress_record_op,
schema::preflight_schema_application_record_op,
},
error::InternalError,
traits::CanisterKind,
};
use std::cell::Cell;
#[cfg(test)]
use crate::db::commit::PreparedRowCommitOp;
#[cfg(test)]
use std::panic::{AssertUnwindSafe, catch_unwind};
#[cfg(test)]
const fn noop_startup_recovery_wakeup() {}
#[cfg(test)]
const DEFAULT_STARTUP_RECOVERY_WAKEUP: Option<fn()> = Some(noop_startup_recovery_wakeup);
#[cfg(not(test))]
const DEFAULT_STARTUP_RECOVERY_WAKEUP: Option<fn()> = None;
thread_local! {
static STARTUP_RECOVERY_WAKEUP: Cell<Option<fn()>> =
const { Cell::new(DEFAULT_STARTUP_RECOVERY_WAKEUP) };
}
#[doc(hidden)]
pub fn install_startup_recovery_wakeup(wakeup: fn()) {
STARTUP_RECOVERY_WAKEUP.with(|installed| installed.set(Some(wakeup)));
}
fn startup_recovery_wakeup() -> Result<fn(), InternalError> {
STARTUP_RECOVERY_WAKEUP
.with(Cell::get)
.ok_or_else(InternalError::executor_invariant)
}
#[cfg(test)]
enum ApplyRollback {
None,
Closure(Box<dyn FnOnce()>),
SinglePreparedRow(PreparedRowCommitOp),
}
#[cfg(test)]
pub(crate) struct CommitApplyGuard {
finished: bool,
rollback: ApplyRollback,
}
#[cfg(test)]
impl CommitApplyGuard {
pub(crate) const fn new(_phase: &'static str) -> Self {
Self {
finished: false,
rollback: ApplyRollback::None,
}
}
pub(crate) fn record_rollback(&mut self, rollback: impl FnOnce() + 'static) {
debug_assert!(
matches!(self.rollback, ApplyRollback::None),
"commit apply guard currently owns exactly one rollback closure",
);
if matches!(self.rollback, ApplyRollback::None) {
self.rollback = ApplyRollback::Closure(Box::new(rollback));
}
}
pub(crate) fn record_single_row_rollback(&mut self, rollback: PreparedRowCommitOp) {
debug_assert!(
matches!(self.rollback, ApplyRollback::None),
"commit apply guard currently owns exactly one rollback payload",
);
if matches!(self.rollback, ApplyRollback::None) {
self.rollback = ApplyRollback::SinglePreparedRow(rollback);
}
}
pub(crate) fn finish(mut self) -> Result<(), InternalError> {
if self.finished {
return Err(InternalError::executor_invariant());
}
self.finished = true;
self.rollback = ApplyRollback::None;
Ok(())
}
fn rollback_best_effort(&mut self) {
if self.finished {
return;
}
match std::mem::replace(&mut self.rollback, ApplyRollback::None) {
ApplyRollback::None => {}
ApplyRollback::Closure(rollback) => {
let _ = catch_unwind(AssertUnwindSafe(rollback));
}
ApplyRollback::SinglePreparedRow(rollback) => {
let _ = catch_unwind(AssertUnwindSafe(|| rollback.apply()));
}
}
}
}
#[cfg(test)]
impl Drop for CommitApplyGuard {
fn drop(&mut self) {
if !self.finished {
self.rollback_best_effort();
}
}
}
#[derive(Debug)]
pub(crate) struct CommitGuard {
startup_recovery_wakeup: fn(),
encoded_control_slot: EncodedCommitControlSlot,
retained_journal_debt: bool,
}
impl CommitGuard {
const fn new(
startup_recovery_wakeup: fn(),
encoded_control_slot: EncodedCommitControlSlot,
retained_journal_debt: bool,
) -> Self {
Self {
startup_recovery_wakeup,
encoded_control_slot,
retained_journal_debt,
}
}
pub(crate) fn journal_batch_bytes(&self, ordinal: usize) -> Result<&[u8], InternalError> {
self.encoded_control_slot.journal_batch_bytes(ordinal)
}
fn clear(&self) -> Result<(), InternalError> {
with_initialized_commit_store(|store| {
store.clear_live_verified(&self.encoded_control_slot)
})?
}
fn ensure_startup_recovery_wakeup(&self) {
(self.startup_recovery_wakeup)();
}
const fn retained_journal_debt(&self) -> bool {
self.retained_journal_debt
}
}
pub(crate) fn begin_commit(marker: CommitMarker) -> Result<CommitGuard, InternalError> {
begin_commit_with_preflighted_mutation_progress(marker, false)
}
pub(in crate::db) fn begin_mutation_progress_commit<C: CanisterKind>(
marker: CommitMarker,
) -> Result<CommitGuard, InternalError> {
let mut progress_count = 0_usize;
for operation in marker.database_control() {
if let DatabaseControlOp::MutationProgress(operation) = operation {
progress_count = progress_count.saturating_add(1);
preflight_mutation_progress_record_op::<C>(operation)?;
}
}
if progress_count != 1 {
return Err(InternalError::store_invariant());
}
begin_commit_with_preflighted_mutation_progress(marker, true)
}
fn begin_commit_with_preflighted_mutation_progress(
marker: CommitMarker,
mutation_progress_preflighted: bool,
) -> Result<CommitGuard, InternalError> {
for operation in marker.database_control() {
match operation {
DatabaseControlOp::SchemaApplication(operation) => {
preflight_schema_application_record_op(operation)?;
}
#[cfg(any(test, feature = "migration"))]
DatabaseControlOp::EntitySourceLineage(operation) => {
crate::db::schema::preflight_entity_source_lineage_catalog_op(operation)?;
}
#[cfg(any(test, feature = "migration"))]
DatabaseControlOp::SchemaMigration(operation) => {
preflight_schema_migration_record_op(operation)?;
}
DatabaseControlOp::MutationProgress(_) => {
if !mutation_progress_preflighted {
return Err(InternalError::store_invariant());
}
}
}
}
let startup_recovery_wakeup = startup_recovery_wakeup()?;
let prepared = with_commit_store(|store| store.prepare_set_if_empty(&marker))?;
let proposed = ExactBacklogMeasurement::from_prepared_marker(&marker, prepared.encoded())?;
let current = current_database_backlog()?;
match admit_backlog(current, proposed, BACKLOG_LIMITS)? {
BacklogAdmission::Admitted { .. } => {}
BacklogAdmission::Pressure(pressure) => {
startup_recovery_wakeup();
return Err(pressure.into_error());
}
}
let encoded_control_slot = with_commit_store(|store| store.publish_prepared_marker(prepared))?;
Ok(CommitGuard::new(
startup_recovery_wakeup,
encoded_control_slot,
proposed.batch_count() != 0,
))
}
pub(crate) fn finish_commit(
mut guard: CommitGuard,
apply: impl FnOnce(&mut CommitGuard) -> Result<(), InternalError>,
) -> Result<(), InternalError> {
let result = apply(&mut guard);
if let Err(error) = result {
guard.ensure_startup_recovery_wakeup();
if with_initialized_commit_store(super::store::CommitStore::is_empty)? {
return Err(InternalError::commit_corruption());
}
return Err(error);
}
if let Err(error) = guard.clear() {
guard.ensure_startup_recovery_wakeup();
return Err(error);
}
if guard.retained_journal_debt() {
guard.ensure_startup_recovery_wakeup();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{
commit::{commit_memory_handle, configure_commit_memory_id},
database_format::initialize_current_database_control_for_tests,
};
struct StartupRecoveryWakeupRestore(Option<fn()>);
impl Drop for StartupRecoveryWakeupRestore {
fn drop(&mut self) {
STARTUP_RECOVERY_WAKEUP.with(|installed| installed.set(self.0));
}
}
#[test]
fn missing_startup_recovery_wakeup_rejects_before_marker_publication() {
const MEMORY_ID: u8 = 242;
const STABLE_KEY: &str = "icydb.test.commit_guard_missing_wakeup.v1";
configure_commit_memory_id(MEMORY_ID, STABLE_KEY)
.expect("commit allocation should configure");
let allocation = super::super::memory::current_commit_memory_allocation()
.expect("commit allocation should resolve");
let memory = commit_memory_handle(allocation).expect("commit memory should open");
initialize_current_database_control_for_tests(&memory);
let previous = STARTUP_RECOVERY_WAKEUP.with(|installed| installed.replace(None));
let _restore = StartupRecoveryWakeupRestore(previous);
let marker = CommitMarker::from_parts([0x6d; 16], Vec::new())
.expect("empty control marker should admit");
let error = begin_commit(marker).expect_err("missing wake-up wiring must fail closed");
assert_eq!(
error.diagnostic().error_code(),
icydb_diagnostic_code::ErrorCode::RUNTIME_INVARIANT_VIOLATION,
);
assert!(
with_commit_store(super::super::store::CommitStore::marker_is_empty)
.expect("commit marker should remain observable"),
"failed admission must not publish durable recovery work",
);
}
}