Skip to main content

ic_backup/ops/persistence/attempt_journal/
mod.rs

1//! Exclusive durable attempt reservations and exact receipt retention; no transport.
2
3use super::json::{create_encoded, encode_json, read_record, write_encoded};
4use super::{
5    BackupLayoutGuard, ExecutionProgressPersistenceError, JournalLock, JournalLockError,
6    OperationPlanPersistenceError, PersistenceError, read_operation_plan,
7};
8use crate::model::artifacts::ArtifactChecksumRecord;
9use crate::model::attempt_journal::{
10    AttemptAuthorityRecord, AttemptJournalRecord, AttemptJournalRecordError,
11    MAX_ATTEMPT_JOURNAL_BYTES, MutationReceiptRequest, ObservationReceiptRequest,
12};
13use crate::model::operation_plan::{OperationPlanError, OperationPlanRecord};
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17/// Exclusive local accounting borrowing stable layout exclusion.
18///
19/// Successful reservation persists consumption before returning its number.
20/// It grants no fresh IC authority, backend settlement or subprocess dispatch permission.
21#[derive(Debug)]
22pub struct AttemptJournalGuard<'a> {
23    layout: &'a BackupLayoutGuard,
24    _lock: JournalLock,
25    record: AttemptJournalRecord,
26    usable: bool,
27}
28
29impl<'a> AttemptJournalGuard<'a> {
30    /// Create original exact identity and limits without replacing retained evidence.
31    ///
32    /// # Errors
33    /// Rejects unsafe/existing journals, lock contention, replaced layouts and failed persistence.
34    pub fn create(
35        layout: &'a BackupLayoutGuard,
36        authority: AttemptAuthorityRecord,
37    ) -> Result<Self, AttemptJournalError> {
38        layout.check_root()?;
39        let path = journal_path(layout, &authority);
40        let lock = JournalLock::acquire(&path)?;
41        let record = AttemptJournalRecord::new(authority);
42        let bytes = encode_json(&record, MAX_ATTEMPT_JOURNAL_BYTES)?;
43        create_encoded(&path, &bytes)?;
44        Ok(Self {
45            layout,
46            _lock: lock,
47            record,
48            usable: true,
49        })
50    }
51    /// Open exact retained identity and original budgets, replaying only local events.
52    ///
53    /// # Errors
54    /// Rejects identity/budget changes, invalid chronology, missing/unsafe/oversized records and locks.
55    pub fn open(
56        layout: &'a BackupLayoutGuard,
57        expected: &AttemptAuthorityRecord,
58    ) -> Result<Self, AttemptJournalError> {
59        layout.check_root()?;
60        let path = journal_path(layout, expected);
61        let lock = JournalLock::acquire(&path)?;
62        let record: AttemptJournalRecord = read_record(&path, MAX_ATTEMPT_JOURNAL_BYTES)?;
63        if record.authority() != expected {
64            return Err(AttemptJournalError::AuthorityMismatch);
65        }
66        Ok(Self {
67            layout,
68            _lock: lock,
69            record,
70            usable: true,
71        })
72    }
73    /// Read validated retained accounting without observing remote state.
74    ///
75    /// # Errors
76    /// Rejects indeterminate writes or replaced layouts.
77    pub fn record(&self) -> Result<&AttemptJournalRecord, AttemptJournalError> {
78        self.check_usable()?;
79        Ok(&self.record)
80    }
81
82    fn into_record(self) -> Result<AttemptJournalRecord, AttemptJournalError> {
83        self.check_usable()?;
84        Ok(self.record)
85    }
86    /// Return the exact journal location for this operation sequence.
87    #[must_use]
88    pub fn path(&self) -> PathBuf {
89        journal_path(self.layout, self.record.authority())
90    }
91    /// Durably consume one mutation attempt before any admitted caller-owned call.
92    ///
93    /// # Errors
94    /// Rejects unresolved attempts, exhaustion, applied operations and persistence failures.
95    pub fn reserve_mutation(&mut self) -> Result<u32, AttemptJournalError> {
96        self.reserve_with(AttemptJournalRecord::reserve_mutation, write_encoded)
97    }
98    /// Reserve only after admitting the exact persisted plan and all original journals.
99    ///
100    /// Every declared prerequisite must retain Applied evidence. Hold no other attempt
101    /// guard; original journals are admitted with sequential locks while this guard
102    /// retains its own lock. No missing journal is created or treated as unused.
103    /// Fresh permissions, authenticated prerequisites, never-dispatched custody and
104    /// backend/application safety remain caller-owned. Success is accounting only.
105    /// # Errors
106    /// Rejects wrong/missing originals, contention, unmet dependencies and ordinary
107    /// pending/exhausted/Applied or indeterminate-write conditions without refunds.
108    pub fn reserve_planned_mutation(
109        &mut self,
110        expected_plan: &ArtifactChecksumRecord,
111    ) -> Result<u32, ExecutionProgressPersistenceError> {
112        let view = super::execution_progress::read_with_current(
113            self.layout,
114            expected_plan,
115            Some(self.record()?),
116        )?;
117        let sequence = self.record.authority().binding().operation_sequence();
118        if view.operations.iter().any(|operation| {
119            operation.operation_sequence == sequence
120                && operation.state
121                    == crate::policy::execution_progress::OperationProgressState::AwaitingDependencies
122        }) {
123            return Err(ExecutionProgressPersistenceError::DependenciesUnapplied(sequence));
124        }
125        Ok(self.reserve_mutation()?)
126    }
127    /// Durably consume one observation bound to an exact unresolved mutation and request.
128    ///
129    /// Fresh authority, paid-effect settlement and ended command custody remain caller-owned.
130    /// # Errors
131    /// Rejects wrong attempts/requests, unresolved observations, exhaustion and failed writes.
132    pub fn reserve_observation(
133        &mut self,
134        mutation: u32,
135        request: &str,
136    ) -> Result<u32, AttemptJournalError> {
137        self.reserve_with(
138            |record| record.reserve_observation(mutation, request),
139            write_encoded,
140        )
141    }
142    /// Reserve one original reconciliation observation after complete retained-plan admission.
143    ///
144    /// Reuses the same mutation/observation accounting owner; complete original evidence
145    /// is required even after exhaustion or reopening. Hold no other attempt guard.
146    /// Fresh read permission, observation dispatch custody and settled authenticated
147    /// outcome qualification remain integration-owned. Lost replies stay pending.
148    /// # Errors
149    /// Rejects missing/changed/held originals, invalid causality and the existing
150    /// reservation/persistence failures. No journal or allowance is recreated.
151    pub fn reserve_planned_observation(
152        &mut self,
153        expected_plan: &ArtifactChecksumRecord,
154        mutation: u32,
155        request: &str,
156    ) -> Result<u32, ExecutionProgressPersistenceError> {
157        self.admit_planned_progress(expected_plan)?;
158        Ok(self.reserve_observation(mutation, request)?)
159    }
160    /// Retain an exact integration-qualified direct mutation reply.
161    ///
162    /// # Errors
163    /// Rejects unmatched identity, absent/unsettled attempts, invalid evidence and failed persistence.
164    pub fn record_mutation(
165        &mut self,
166        receipt: MutationReceiptRequest,
167    ) -> Result<(), AttemptJournalError> {
168        self.reserve_with(|record| record.record_mutation(receipt), write_encoded)
169    }
170    /// Retain an exact observation; unresolved evidence never authorizes another mutation.
171    ///
172    /// The integration qualifies completed observation and paid-effect settlement.
173    /// A lost observation response stays pending until qualified settlement.
174    ///
175    /// # Errors
176    /// Rejects unmatched identity, absent/unsettled attempts, invalid evidence and failed persistence.
177    pub fn record_observation(
178        &mut self,
179        receipt: ObservationReceiptRequest,
180    ) -> Result<(), AttemptJournalError> {
181        self.reserve_with(|record| record.record_observation(receipt), write_encoded)
182    }
183
184    /// Record an independently qualified observation after complete original-plan admission.
185    ///
186    /// Hold no other attempt guards. Original journals are read with sequential locks;
187    /// missing evidence never means unused allowance. This reuses the existing receipt
188    /// transition and durable writer, without another reservation or spending owner.
189    /// Authentication, settled outcome and retained evidence remain integration-owned.
190    /// # Errors
191    /// Rejects missing/changed/held originals, invalid causality or receipt, and failed IO.
192    pub fn record_planned_observation(
193        &mut self,
194        expected_plan: &ArtifactChecksumRecord,
195        receipt: ObservationReceiptRequest,
196    ) -> Result<(), ExecutionProgressPersistenceError> {
197        self.admit_planned_progress(expected_plan)?;
198        Ok(self.record_observation(receipt)?)
199    }
200
201    pub(crate) fn admit_planned_progress(
202        &self,
203        expected_plan: &ArtifactChecksumRecord,
204    ) -> Result<(), ExecutionProgressPersistenceError> {
205        super::execution_progress::read_with_current(
206            self.layout,
207            expected_plan,
208            Some(self.record()?),
209        )?;
210        Ok(())
211    }
212
213    fn reserve_with<T>(
214        &mut self,
215        transition: impl FnOnce(&mut AttemptJournalRecord) -> Result<T, AttemptJournalRecordError>,
216        write: impl FnOnce(&Path, &[u8]) -> Result<(), PersistenceError>,
217    ) -> Result<T, AttemptJournalError> {
218        self.check_usable()?;
219        let mut next = self.record.clone();
220        let result = transition(&mut next)?;
221        let bytes = encode_json(&next, MAX_ATTEMPT_JOURNAL_BYTES)?;
222        let path = self.path();
223        super::json::replace_cached_record(&mut self.record, &mut self.usable, next, || {
224            write(&path, &bytes)
225        })?;
226        Ok(result)
227    }
228    fn check_usable(&self) -> Result<(), AttemptJournalError> {
229        if !self.usable {
230            return Err(AttemptJournalError::IndeterminateWrite);
231        }
232        self.layout.check_root()?;
233        Ok(())
234    }
235}
236
237pub(super) fn read_original_journals(
238    layout: &BackupLayoutGuard,
239    authorities: &[AttemptAuthorityRecord],
240    current: Option<&AttemptJournalRecord>,
241) -> Result<Vec<AttemptJournalRecord>, AttemptJournalError> {
242    layout.check_root()?;
243    if let Some(current) = current
244        && !authorities.contains(current.authority())
245    {
246        return Err(AttemptJournalError::AuthorityMismatch);
247    }
248    let mut journals = Vec::with_capacity(authorities.len());
249    for authority in authorities {
250        if let Some(current) = current
251            && current.authority() == authority
252        {
253            journals.push(current.clone());
254        } else {
255            let guard = AttemptJournalGuard::open(layout, authority)?;
256            journals.push(guard.into_record()?);
257        }
258    }
259    layout.check_root()?;
260    Ok(journals)
261}
262
263pub(super) fn read_original_plan_journals<E>(
264    layout: &BackupLayoutGuard,
265    expected_plan: &ArtifactChecksumRecord,
266    current: Option<&AttemptJournalRecord>,
267) -> Result<(OperationPlanRecord, Vec<AttemptJournalRecord>), E>
268where
269    E: From<OperationPlanPersistenceError> + From<OperationPlanError> + From<AttemptJournalError>,
270{
271    let plan = read_operation_plan(layout, expected_plan)?;
272    let authorities = plan.attempt_authorities()?;
273    let journals = read_original_journals(layout, &authorities, current)?;
274    Ok((plan, journals))
275}
276
277fn journal_path(layout: &BackupLayoutGuard, authority: &AttemptAuthorityRecord) -> PathBuf {
278    layout.root().join(format!(
279        "attempt-{}.json",
280        authority.binding().operation_sequence()
281    ))
282}
283
284/// Typed exact admission, local accounting or durable publication failure.
285#[derive(Debug, Error)]
286pub enum AttemptJournalError {
287    /// Retained identity or original ceilings differ from current exact selection.
288    #[error("attempt journal authority mismatch")]
289    AuthorityMismatch,
290    /// A write may have completed; drop and reopen exact retained evidence.
291    #[error("attempt journal write outcome indeterminate; reopen retained evidence")]
292    IndeterminateWrite,
293    /// Model identity, chronology or allowance admission failed.
294    #[error(transparent)]
295    Record(#[from] AttemptJournalRecordError),
296    /// Journal/layout exclusion failed.
297    #[error(transparent)]
298    Lock(#[from] JournalLockError),
299    /// Bounded durable record access failed.
300    #[error(transparent)]
301    Persistence(#[from] PersistenceError),
302}
303
304#[cfg(all(test, unix))]
305mod tests;