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::{check_json_size, create_encoded, encode_json, write_encoded};
4use super::{
5    BackupLayoutGuard, ExecutionProgressPersistenceError, JournalLock, JournalLockError,
6    OperationPlanPersistenceError, PersistenceError, read_json, 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_json(&path, MAX_ATTEMPT_JOURNAL_BYTES)?;
63        check_json_size(&record, MAX_ATTEMPT_JOURNAL_BYTES)?;
64        if record.authority() != expected {
65            return Err(AttemptJournalError::AuthorityMismatch);
66        }
67        Ok(Self {
68            layout,
69            _lock: lock,
70            record,
71            usable: true,
72        })
73    }
74    /// Read validated retained accounting without observing remote state.
75    ///
76    /// # Errors
77    /// Rejects indeterminate writes or replaced layouts.
78    pub fn record(&self) -> Result<&AttemptJournalRecord, AttemptJournalError> {
79        self.check_usable()?;
80        Ok(&self.record)
81    }
82    /// Return the exact journal location for this operation sequence.
83    #[must_use]
84    pub fn path(&self) -> PathBuf {
85        journal_path(self.layout, self.record.authority())
86    }
87    /// Durably consume one mutation attempt before any admitted caller-owned call.
88    ///
89    /// # Errors
90    /// Rejects unresolved attempts, exhaustion, applied operations and persistence failures.
91    pub fn reserve_mutation(&mut self) -> Result<u32, AttemptJournalError> {
92        self.reserve_with(AttemptJournalRecord::reserve_mutation, write_encoded)
93    }
94    /// Reserve only after admitting the exact persisted plan and all original journals.
95    ///
96    /// Every declared prerequisite must retain Applied evidence. Hold no other attempt
97    /// guard; original journals are admitted with sequential locks while this guard
98    /// retains its own lock. No missing journal is created or treated as unused.
99    /// Fresh permissions, authenticated prerequisites, never-dispatched custody and
100    /// backend/application safety remain caller-owned. Success is accounting only.
101    /// # Errors
102    /// Rejects wrong/missing originals, contention, unmet dependencies and ordinary
103    /// pending/exhausted/Applied or indeterminate-write conditions without refunds.
104    pub fn reserve_planned_mutation(
105        &mut self,
106        expected_plan: &ArtifactChecksumRecord,
107    ) -> Result<u32, ExecutionProgressPersistenceError> {
108        let view = super::execution_progress::read_with_current(
109            self.layout,
110            expected_plan,
111            Some(self.record()?),
112        )?;
113        let sequence = self.record.authority().binding().operation_sequence();
114        if view.operations.iter().any(|operation| {
115            operation.operation_sequence == sequence
116                && operation.state
117                    == crate::policy::execution_progress::OperationProgressState::AwaitingDependencies
118        }) {
119            return Err(ExecutionProgressPersistenceError::DependenciesUnapplied(sequence));
120        }
121        Ok(self.reserve_mutation()?)
122    }
123    /// Durably consume one observation bound to an exact unresolved mutation and request.
124    ///
125    /// Fresh authority, paid-effect settlement and ended command custody remain caller-owned.
126    /// # Errors
127    /// Rejects wrong attempts/requests, unresolved observations, exhaustion and failed writes.
128    pub fn reserve_observation(
129        &mut self,
130        mutation: u32,
131        request: &str,
132    ) -> Result<u32, AttemptJournalError> {
133        self.reserve_with(
134            |record| record.reserve_observation(mutation, request),
135            write_encoded,
136        )
137    }
138    /// Reserve one original reconciliation observation after complete retained-plan admission.
139    ///
140    /// Reuses the same mutation/observation accounting owner; complete original evidence
141    /// is required even after exhaustion or reopening. Hold no other attempt guard.
142    /// Fresh read permission, observation dispatch custody and settled authenticated
143    /// outcome qualification remain integration-owned. Lost replies stay pending.
144    /// # Errors
145    /// Rejects missing/changed/held originals, invalid causality and the existing
146    /// reservation/persistence failures. No journal or allowance is recreated.
147    pub fn reserve_planned_observation(
148        &mut self,
149        expected_plan: &ArtifactChecksumRecord,
150        mutation: u32,
151        request: &str,
152    ) -> Result<u32, ExecutionProgressPersistenceError> {
153        super::execution_progress::read_with_current(
154            self.layout,
155            expected_plan,
156            Some(self.record()?),
157        )?;
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        self.usable = false;
223        write(&self.path(), &bytes)?;
224        self.record = next;
225        self.usable = true;
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.record()?.clone());
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;