Skip to main content

ozzy_journal/
progress.rs

1//! Generation-scoped accounting for complete journal writes and syncs.
2//!
3//! This does not perform I/O or decide commit. The adapter reports a write only
4//! after a complete sealed group, and a sync only after the required barrier.
5//! Tickets identify local actions, not hashes, quorum evidence, or disk proofs.
6
7/// One active journal writer incarnation, supplied by the owning runtime.
8///
9/// Never reuse a value across journals, reopen, or selected-lineage replacement.
10/// It is independent of the persisted segment/manifest generation and VSR view.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct JournalGeneration(pub u128);
13
14/// Contiguous group-operation prefix. Zero denotes the empty journal.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
16pub struct OpNumber(pub u64);
17
18/// One admitted, indivisible local write group. Fields cannot be forged externally.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct WriteTicket {
21    generation: JournalGeneration,
22    first: OpNumber,
23    through: OpNumber,
24}
25
26impl WriteTicket {
27    /// Writer incarnation that admitted this group.
28    pub const fn generation(self) -> JournalGeneration {
29        self.generation
30    }
31    /// First operation in this group, inclusive.
32    pub const fn first(self) -> OpNumber {
33        self.first
34    }
35    /// Last operation in this group, inclusive.
36    pub const fn through(self) -> OpNumber {
37        self.through
38    }
39}
40
41/// Prefix captured when a synchronization request is issued.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct SyncTicket {
44    generation: JournalGeneration,
45    through: OpNumber,
46}
47
48impl SyncTicket {
49    /// Writer incarnation that issued this synchronization.
50    pub const fn generation(self) -> JournalGeneration {
51        self.generation
52    }
53    /// Complete written prefix this synchronization may acknowledge.
54    pub const fn through(self) -> OpNumber {
55        self.through
56    }
57}
58
59/// Read-only local progress. None of these positions implies quorum commit.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct JournalSnapshot {
62    /// Current writer incarnation.
63    pub generation: JournalGeneration,
64    /// Last admitted operation.
65    pub accepted: OpNumber,
66    /// Last operation in a completely written group.
67    pub written: OpNumber,
68    /// Last operation covered by successful sync evidence.
69    pub durable: OpNumber,
70    /// Uncertain I/O outcome blocks subsequent actions until recovery.
71    pub faulted: bool,
72}
73
74/// Single-writer local completion state. No allocation, runtime, or I/O.
75#[derive(Debug)]
76pub struct JournalProgress {
77    state: JournalSnapshot,
78}
79
80impl JournalProgress {
81    /// Start from an independently verified and synchronized recovered prefix.
82    ///
83    /// The caller must complete storage recovery before supplying this prefix.
84    /// Bytes visible only in a surviving OS page cache are not stable evidence.
85    /// Reopening requires a fresh generation, including after an I/O error.
86    pub const fn recover(generation: JournalGeneration, through: OpNumber) -> Self {
87        Self {
88            state: JournalSnapshot {
89                generation,
90                accepted: through,
91                written: through,
92                durable: through,
93                faulted: false,
94            },
95        }
96    }
97
98    /// Inspect progress without obtaining mutation authority.
99    pub const fn snapshot(&self) -> JournalSnapshot {
100        self.state
101    }
102
103    /// Reserve a contiguous group after external payload/capacity validation.
104    /// Errors leave all state unchanged. Zero and counter wrap are rejected.
105    pub fn admit(&mut self, operations: u64) -> Result<WriteTicket, ProgressError> {
106        self.require_active(self.state.generation)?;
107        if operations == 0 {
108            return Err(ProgressError::EmptyGroup);
109        }
110        let through = self
111            .state
112            .accepted
113            .0
114            .checked_add(operations)
115            .ok_or(ProgressError::OpExhausted)?;
116        let ticket = WriteTicket {
117            generation: self.state.generation,
118            first: OpNumber(self.state.accepted.0 + 1),
119            through: OpNumber(through),
120        };
121        self.state.accepted = ticket.through;
122        Ok(ticket)
123    }
124
125    /// Report a complete sealed write, in journal order. Duplicates are harmless.
126    /// Partial writes must not call this method. They remain the adapter's work.
127    pub fn complete_write(&mut self, ticket: WriteTicket) -> Result<(), ProgressError> {
128        self.require_active(ticket.generation)?;
129        if ticket.through > self.state.accepted {
130            return Err(ProgressError::BeyondAccepted);
131        }
132        if ticket.through <= self.state.written {
133            return Ok(());
134        }
135        if Some(ticket.first.0) != self.state.written.0.checked_add(1) {
136            return Err(ProgressError::WriteGap);
137        }
138        self.state.written = ticket.through;
139        Ok(())
140    }
141
142    /// Capture the complete written prefix before submitting a sync action.
143    pub fn begin_sync(&self) -> Result<SyncTicket, ProgressError> {
144        self.require_active(self.state.generation)?;
145        Ok(SyncTicket {
146            generation: self.state.generation,
147            through: self.state.written,
148        })
149    }
150
151    /// Report successful sync for the captured prefix, not newer written data.
152    /// Duplicate/older completions cannot lower the durable prefix.
153    pub fn complete_sync(&mut self, ticket: SyncTicket) -> Result<(), ProgressError> {
154        self.require_active(ticket.generation)?;
155        if ticket.through > self.state.written {
156            return Err(ProgressError::BeyondWritten);
157        }
158        self.state.durable = self.state.durable.max(ticket.through);
159        Ok(())
160    }
161
162    /// Report one complete write whose backend operation also synchronized the
163    /// exact group before returning. This supports transactional local adapters
164    /// that do not expose separate write and sync callbacks.
165    pub fn complete_durable_write(&mut self, ticket: WriteTicket) -> Result<(), ProgressError> {
166        self.complete_write(ticket)?;
167        self.state.durable = self.state.durable.max(ticket.through);
168        Ok(())
169    }
170
171    /// An uncertain write/sync faults this generation. Past evidence is retained.
172    /// A stale error from a replaced generation cannot fault its replacement.
173    pub fn fail(&mut self, generation: JournalGeneration) -> Result<(), ProgressError> {
174        if generation != self.state.generation {
175            return Err(ProgressError::StaleGeneration);
176        }
177        self.state.faulted = true;
178        Ok(())
179    }
180
181    fn require_active(&self, generation: JournalGeneration) -> Result<(), ProgressError> {
182        if generation != self.state.generation {
183            return Err(ProgressError::StaleGeneration);
184        }
185        if self.state.faulted {
186            return Err(ProgressError::Faulted);
187        }
188        Ok(())
189    }
190}
191
192/// Invalid or stale journal transition. No error advances any prefix.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
194pub enum ProgressError {
195    /// The writer has an uncertain I/O outcome and needs recovery.
196    #[error("journal generation is faulted")]
197    Faulted,
198    /// Completion belongs to another writer incarnation.
199    #[error("stale journal generation")]
200    StaleGeneration,
201    /// A write group must contain at least one operation.
202    #[error("empty journal group")]
203    EmptyGroup,
204    /// Operation numbering cannot wrap.
205    #[error("journal operation number exhausted")]
206    OpExhausted,
207    /// Completion claims operations that were never admitted.
208    #[error("write completion exceeds admitted prefix")]
209    BeyondAccepted,
210    /// An earlier complete write has not been reported.
211    #[error("write completion leaves a journal gap")]
212    WriteGap,
213    /// Sync claims data not known to have been completely written.
214    #[error("sync completion exceeds written prefix")]
215    BeyondWritten,
216}
217
218#[cfg(kani)]
219mod proofs {
220    use super::*;
221
222    #[kani::proof]
223    fn transitions_preserve_prefix_order_or_leave_state_unchanged() {
224        let before = JournalSnapshot {
225            generation: JournalGeneration(kani::any()),
226            accepted: OpNumber(kani::any()),
227            written: OpNumber(kani::any()),
228            durable: OpNumber(kani::any()),
229            faulted: kani::any(),
230        };
231        // Inductive precondition: every public constructor/transition must
232        // establish this ordering. Event fields themselves are unrestricted.
233        kani::assume(before.durable <= before.written && before.written <= before.accepted);
234        let mut progress = JournalProgress { state: before };
235        let generation = JournalGeneration(kani::any());
236        let through = OpNumber(kani::any());
237        let result = match kani::any::<u8>() {
238            0 => progress.admit(kani::any()).map(|_| ()),
239            1 => progress.complete_write(WriteTicket {
240                generation,
241                first: OpNumber(kani::any()),
242                through,
243            }),
244            2 => progress.complete_sync(SyncTicket {
245                generation,
246                through,
247            }),
248            3 => progress.complete_durable_write(WriteTicket {
249                generation,
250                first: OpNumber(kani::any()),
251                through,
252            }),
253            _ => progress.fail(generation),
254        };
255        let after = progress.snapshot();
256        assert!(after.durable <= after.written && after.written <= after.accepted);
257        assert!(after.durable >= before.durable);
258        assert!(after.written >= before.written);
259        assert!(after.accepted >= before.accepted);
260        if result.is_err() {
261            assert_eq!(after, before);
262        }
263    }
264
265    #[kani::proof]
266    fn admission_is_atomic_and_cannot_wrap() {
267        let start: u64 = kani::any();
268        let count: u64 = kani::any();
269        let mut progress = JournalProgress::recover(JournalGeneration(1), OpNumber(start));
270        let before = progress.snapshot();
271        match progress.admit(count) {
272            Ok(ticket) => {
273                assert!(count > 0);
274                assert!(ticket.first.0 > start);
275                assert_eq!(Some(ticket.through.0), start.checked_add(count));
276                assert_eq!(progress.snapshot().written, before.written);
277                assert_eq!(progress.snapshot().durable, before.durable);
278            }
279            Err(_) => {
280                assert!(count == 0 || start.checked_add(count).is_none());
281                assert_eq!(progress.snapshot(), before);
282            }
283        }
284    }
285
286    #[kani::proof]
287    fn sync_cannot_acknowledge_later_writes() {
288        let start: u64 = kani::any();
289        let first_count = u64::from(kani::any::<u8>()) + 1;
290        let second_count = u64::from(kani::any::<u8>()) + 1;
291        let mut progress = JournalProgress::recover(JournalGeneration(1), OpNumber(start));
292        let Ok(first) = progress.admit(first_count) else {
293            return;
294        };
295        progress.complete_write(first).unwrap();
296        let sync = progress.begin_sync().unwrap();
297        let Ok(second) = progress.admit(second_count) else {
298            return;
299        };
300        progress.complete_write(second).unwrap();
301        progress.complete_sync(sync).unwrap();
302        assert_eq!(progress.snapshot().durable, first.through);
303        assert!(progress.snapshot().durable < progress.snapshot().written);
304    }
305}