ic-backup 0.8.1

Host-side snapshot backup and same-release recovery for Internet Computer canisters
Documentation
//! Exclusive durable attempt reservations and exact receipt retention; no transport.

use super::{
    BackupLayoutGuard, ExecutionProgressPersistenceError, JournalLock, JournalLockError,
    PersistenceError, create_json_durable, read_json, write_json_durable,
};
use crate::model::artifacts::ArtifactChecksumRecord;
use crate::model::attempt_journal::{
    AttemptAuthorityRecord, AttemptJournalRecord, AttemptJournalRecordError,
    MAX_ATTEMPT_JOURNAL_BYTES, MutationReceiptRequest, ObservationReceiptRequest,
};
use std::path::{Path, PathBuf};
use thiserror::Error;

/// Exclusive local accounting borrowing stable layout exclusion.
///
/// Successful reservation persists consumption before returning its number.
/// It grants no fresh IC authority, backend settlement or subprocess dispatch permission.
#[derive(Debug)]
pub struct AttemptJournalGuard<'a> {
    layout: &'a BackupLayoutGuard,
    _lock: JournalLock,
    record: AttemptJournalRecord,
    usable: bool,
}

impl<'a> AttemptJournalGuard<'a> {
    /// Create original exact identity and limits without replacing retained evidence.
    ///
    /// # Errors
    /// Rejects unsafe/existing journals, lock contention, replaced layouts and failed persistence.
    pub fn create(
        layout: &'a BackupLayoutGuard,
        authority: AttemptAuthorityRecord,
    ) -> Result<Self, AttemptJournalError> {
        layout.check_root()?;
        let path = journal_path(layout, &authority);
        let lock = JournalLock::acquire(&path)?;
        let record = AttemptJournalRecord::new(authority);
        check_size(&record)?;
        create_json_durable(&path, &record)?;
        Ok(Self {
            layout,
            _lock: lock,
            record,
            usable: true,
        })
    }
    /// Open exact retained identity and original budgets, replaying only local events.
    ///
    /// # Errors
    /// Rejects identity/budget changes, invalid chronology, missing/unsafe/oversized records and locks.
    pub fn open(
        layout: &'a BackupLayoutGuard,
        expected: &AttemptAuthorityRecord,
    ) -> Result<Self, AttemptJournalError> {
        layout.check_root()?;
        let path = journal_path(layout, expected);
        let lock = JournalLock::acquire(&path)?;
        let record: AttemptJournalRecord = read_json(&path, MAX_ATTEMPT_JOURNAL_BYTES)?;
        check_size(&record)?;
        if record.authority() != expected {
            return Err(AttemptJournalError::AuthorityMismatch);
        }
        Ok(Self {
            layout,
            _lock: lock,
            record,
            usable: true,
        })
    }
    /// Read validated retained accounting without observing remote state.
    ///
    /// # Errors
    /// Rejects indeterminate writes or replaced layouts.
    pub fn record(&self) -> Result<&AttemptJournalRecord, AttemptJournalError> {
        self.check_usable()?;
        Ok(&self.record)
    }
    /// Return the exact journal location for this operation sequence.
    #[must_use]
    pub fn path(&self) -> PathBuf {
        journal_path(self.layout, self.record.authority())
    }
    /// Durably consume one mutation attempt before any admitted caller-owned call.
    ///
    /// # Errors
    /// Rejects unresolved attempts, exhaustion, applied operations and persistence failures.
    pub fn reserve_mutation(&mut self) -> Result<u32, AttemptJournalError> {
        self.reserve_with(AttemptJournalRecord::reserve_mutation, write_json_durable)
    }
    /// Reserve only after admitting the exact persisted plan and all original journals.
    ///
    /// Every declared prerequisite must retain Applied evidence. Hold no other attempt
    /// guard; original journals are admitted with sequential locks while this guard
    /// retains its own lock. No missing journal is created or treated as unused.
    /// Fresh permissions, authenticated prerequisites, never-dispatched custody and
    /// backend/application safety remain caller-owned. Success is accounting only.
    /// # Errors
    /// Rejects wrong/missing originals, contention, unmet dependencies and ordinary
    /// pending/exhausted/Applied or indeterminate-write conditions without refunds.
    pub fn reserve_planned_mutation(
        &mut self,
        expected_plan: &ArtifactChecksumRecord,
    ) -> Result<u32, ExecutionProgressPersistenceError> {
        let view = super::execution_progress::read_with_current(
            self.layout,
            expected_plan,
            Some(self.record()?),
        )?;
        let sequence = self.record.authority().binding().operation_sequence();
        if view.operations.iter().any(|operation| {
            operation.operation_sequence == sequence
                && operation.state
                    == crate::policy::execution_progress::OperationProgressState::AwaitingDependencies
        }) {
            return Err(ExecutionProgressPersistenceError::DependenciesUnapplied(sequence));
        }
        Ok(self.reserve_mutation()?)
    }
    /// Durably consume one observation bound to an exact unresolved mutation and request.
    ///
    /// Fresh authority, paid-effect settlement and ended command custody remain caller-owned.
    /// # Errors
    /// Rejects wrong attempts/requests, unresolved observations, exhaustion and failed writes.
    pub fn reserve_observation(
        &mut self,
        mutation: u32,
        request: &str,
    ) -> Result<u32, AttemptJournalError> {
        self.reserve_with(
            |record| record.reserve_observation(mutation, request),
            write_json_durable,
        )
    }
    /// Reserve one original reconciliation observation after complete retained-plan admission.
    ///
    /// Reuses the same mutation/observation accounting owner; complete original evidence
    /// is required even after exhaustion or reopening. Hold no other attempt guard.
    /// Fresh read permission, observation dispatch custody and settled authenticated
    /// outcome qualification remain integration-owned. Lost replies stay pending.
    /// # Errors
    /// Rejects missing/changed/held originals, invalid causality and the existing
    /// reservation/persistence failures. No journal or allowance is recreated.
    pub fn reserve_planned_observation(
        &mut self,
        expected_plan: &ArtifactChecksumRecord,
        mutation: u32,
        request: &str,
    ) -> Result<u32, ExecutionProgressPersistenceError> {
        super::execution_progress::read_with_current(
            self.layout,
            expected_plan,
            Some(self.record()?),
        )?;
        Ok(self.reserve_observation(mutation, request)?)
    }
    /// Retain an exact integration-qualified direct mutation reply.
    ///
    /// # Errors
    /// Rejects unmatched identity, absent/unsettled attempts, invalid evidence and failed persistence.
    pub fn record_mutation(
        &mut self,
        receipt: MutationReceiptRequest,
    ) -> Result<(), AttemptJournalError> {
        self.reserve_with(|record| record.record_mutation(receipt), write_json_durable)
    }
    /// Retain an exact observation; unresolved evidence never authorizes another mutation.
    ///
    /// The integration qualifies completed observation and paid-effect settlement.
    /// A lost observation response stays pending until qualified settlement.
    ///
    /// # Errors
    /// Rejects unmatched identity, absent/unsettled attempts, invalid evidence and failed persistence.
    pub fn record_observation(
        &mut self,
        receipt: ObservationReceiptRequest,
    ) -> Result<(), AttemptJournalError> {
        self.reserve_with(
            |record| record.record_observation(receipt),
            write_json_durable,
        )
    }

    fn reserve_with<T>(
        &mut self,
        transition: impl FnOnce(&mut AttemptJournalRecord) -> Result<T, AttemptJournalRecordError>,
        write: impl FnOnce(&Path, &AttemptJournalRecord) -> Result<(), PersistenceError>,
    ) -> Result<T, AttemptJournalError> {
        self.check_usable()?;
        let mut next = self.record.clone();
        let result = transition(&mut next)?;
        check_size(&next)?;
        self.usable = false;
        write(&self.path(), &next)?;
        self.record = next;
        self.usable = true;
        Ok(result)
    }
    fn check_usable(&self) -> Result<(), AttemptJournalError> {
        if !self.usable {
            return Err(AttemptJournalError::IndeterminateWrite);
        }
        self.layout.check_root()?;
        Ok(())
    }
}

pub(super) fn read_original_journals(
    layout: &BackupLayoutGuard,
    authorities: &[AttemptAuthorityRecord],
    current: Option<&AttemptJournalRecord>,
) -> Result<Vec<AttemptJournalRecord>, AttemptJournalError> {
    layout.check_root()?;
    if let Some(current) = current
        && !authorities.contains(current.authority())
    {
        return Err(AttemptJournalError::AuthorityMismatch);
    }
    let mut journals = Vec::with_capacity(authorities.len());
    for authority in authorities {
        if let Some(current) = current
            && current.authority() == authority
        {
            journals.push(current.clone());
        } else {
            let guard = AttemptJournalGuard::open(layout, authority)?;
            journals.push(guard.record()?.clone());
        }
    }
    layout.check_root()?;
    Ok(journals)
}

fn journal_path(layout: &BackupLayoutGuard, authority: &AttemptAuthorityRecord) -> PathBuf {
    layout.root().join(format!(
        "attempt-{}.json",
        authority.binding().operation_sequence()
    ))
}
fn check_size(record: &AttemptJournalRecord) -> Result<(), PersistenceError> {
    super::json::check_json_size(record, MAX_ATTEMPT_JOURNAL_BYTES)
}

/// Typed exact admission, local accounting or durable publication failure.
#[derive(Debug, Error)]
pub enum AttemptJournalError {
    /// Retained identity or original ceilings differ from current exact selection.
    #[error("attempt journal authority mismatch")]
    AuthorityMismatch,
    /// A write may have completed; drop and reopen exact retained evidence.
    #[error("attempt journal write outcome indeterminate; reopen retained evidence")]
    IndeterminateWrite,
    /// Model identity, chronology or allowance admission failed.
    #[error(transparent)]
    Record(#[from] AttemptJournalRecordError),
    /// Journal/layout exclusion failed.
    #[error(transparent)]
    Lock(#[from] JournalLockError),
    /// Bounded durable record access failed.
    #[error(transparent)]
    Persistence(#[from] PersistenceError),
}

#[cfg(all(test, unix))]
mod tests;