Skip to main content

link_cli/transactions/
recovery.rs

1//! Transition replay, crash recovery and log retention for
2//! [`GenericTransactionsDecorator`].
3//!
4//! Extracted from `transactions/mod.rs` for issue #100: the file had grown
5//! past the 1000-line limit enforced by `rust/scripts/check-file-size.rs`.
6//! These are the paths that read the sidecar log back — applying and
7//! reverting individual transitions, replaying an interrupted run, and
8//! trimming or archiving the log once it grows — as opposed to the write
9//! paths that record new transitions in the parent module.
10
11use std::path::Path;
12
13use doublets::data::LinkReference;
14
15use crate::error::LinkError;
16use crate::storage::LinksStorage;
17
18use super::log::TransitionLogStore;
19use super::types::{
20    GenericTransition, LogRetentionPolicy, TransitionKind, APPLIED_MARKER_PREFIX,
21    COMMIT_MARKER_PREFIX, ROLLBACK_MARKER_PREFIX, TRANSITION_NAME_PREFIX,
22};
23use super::{insert_ordered, new_transaction_id, now_unix_ms, GenericTransactionsDecorator};
24
25impl<T, S, L> GenericTransactionsDecorator<T, S, L>
26where
27    T: LinkReference,
28    S: LinksStorage<T>,
29    L: TransitionLogStore,
30{
31    /// Public helper for higher-level decorators (e.g. version control)
32    /// — applies a single transition without writing a new log entry.
33    pub fn apply_transition(&mut self, transition: &GenericTransition<T>) {
34        self.replaying = true;
35        self.try_apply_transition(transition, false);
36        self.replaying = false;
37    }
38
39    /// Public helper for higher-level decorators (e.g. version control)
40    /// — reverts a single transition without writing a new log entry.
41    pub fn revert_transition(&mut self, transition: &GenericTransition<T>) {
42        self.replaying = true;
43        self.try_revert_transition(transition);
44        self.replaying = false;
45    }
46
47    pub(super) fn try_apply_transition(
48        &mut self,
49        transition: &GenericTransition<T>,
50        record_applied: bool,
51    ) {
52        let zero = T::from_byte(0);
53        let result: Result<(), LinkError> = match transition.kind {
54            TransitionKind::Create => {
55                if transition.after.index != zero && !self.inner.link_exists(transition.after.index)
56                {
57                    self.inner
58                        .ensure_link_created(transition.after.index)
59                        .and_then(|_| {
60                            self.inner
61                                .update_link(
62                                    transition.after.index,
63                                    transition.after.source,
64                                    transition.after.target,
65                                )
66                                .map(|_| ())
67                        })
68                } else {
69                    Ok(())
70                }
71            }
72            TransitionKind::Update => {
73                if transition.after.index != zero && self.inner.link_exists(transition.after.index)
74                {
75                    self.inner
76                        .update_link(
77                            transition.after.index,
78                            transition.after.source,
79                            transition.after.target,
80                        )
81                        .map(|_| ())
82                } else {
83                    Ok(())
84                }
85            }
86            TransitionKind::Delete => {
87                if transition.before.index != zero
88                    && self.inner.link_exists(transition.before.index)
89                {
90                    self.inner.delete_link(transition.before.index).map(|_| ())
91                } else {
92                    Ok(())
93                }
94            }
95        };
96        if let Err(e) = result {
97            if self.trace {
98                eprintln!(
99                    "[Transactions] Failed to apply transition seq={}: {e}",
100                    transition.sequence
101                );
102            }
103        }
104        if record_applied {
105            let _ = self.mark_applied(transition);
106        }
107    }
108
109    pub(super) fn try_revert_transition(&mut self, transition: &GenericTransition<T>) {
110        let zero = T::from_byte(0);
111        let result: Result<(), LinkError> = match transition.kind {
112            TransitionKind::Create => {
113                if transition.after.index != zero && self.inner.link_exists(transition.after.index)
114                {
115                    self.inner.delete_link(transition.after.index).map(|_| ())
116                } else {
117                    Ok(())
118                }
119            }
120            TransitionKind::Update => {
121                if transition.before.index != zero
122                    && self.inner.link_exists(transition.before.index)
123                {
124                    self.inner
125                        .update_link(
126                            transition.before.index,
127                            transition.before.source,
128                            transition.before.target,
129                        )
130                        .map(|_| ())
131                } else {
132                    Ok(())
133                }
134            }
135            TransitionKind::Delete => {
136                if transition.before.index != zero
137                    && !self.inner.link_exists(transition.before.index)
138                {
139                    self.inner
140                        .ensure_link_created(transition.before.index)
141                        .and_then(|_| {
142                            self.inner
143                                .update_link(
144                                    transition.before.index,
145                                    transition.before.source,
146                                    transition.before.target,
147                                )
148                                .map(|_| ())
149                        })
150                } else {
151                    Ok(())
152                }
153            }
154        };
155        if let Err(e) = result {
156            if self.trace {
157                eprintln!(
158                    "[Transactions] Failed to revert transition seq={}: {e}",
159                    transition.sequence
160                );
161            }
162        }
163    }
164
165    pub(super) fn mark_applied(
166        &mut self,
167        transition: &GenericTransition<T>,
168    ) -> Result<(), LinkError> {
169        if self.applied.insert(transition.sequence) {
170            self.write_marker(&format!("{APPLIED_MARKER_PREFIX}{}", transition.sequence))?;
171            if transition.sequence > self.applied_sequence {
172                self.applied_sequence = transition.sequence;
173            }
174        }
175        Ok(())
176    }
177
178    // ----- Recovery -------------------------------------------------------
179
180    /// Rebuilds the in-memory log and marker tables from the sidecar
181    /// log store and re-applies committed-but-unapplied side-effects.
182    ///
183    /// Entries that cannot be parsed are skipped: an append-only log
184    /// can end in the partial entry of a crashed write, and the
185    /// links-backed log can hold names that belong to other features.
186    /// An entry whose addresses do not fit into `T` is *not* skipped —
187    /// that means the log was written by a wider address type and
188    /// silently dropping it would corrupt the recovered state.
189    pub fn recover(&mut self) -> Result<(), LinkError> {
190        self.log.clear();
191        self.committed.clear();
192        self.rolled_back.clear();
193        self.applied.clear();
194        self.sequence_counter = 0;
195        self.applied_sequence = 0;
196
197        for entry in self.log_store.read_log_entries()? {
198            if let Some(payload) = entry.strip_prefix(TRANSITION_NAME_PREFIX) {
199                match GenericTransition::<T>::parse(payload) {
200                    Ok(transition) => {
201                        insert_ordered(&mut self.log, transition);
202                        if transition.sequence > self.sequence_counter {
203                            self.sequence_counter = transition.sequence;
204                        }
205                    }
206                    Err(LinkError::AddressOutOfRange(value)) => {
207                        return Err(LinkError::AddressOutOfRange(value))
208                    }
209                    Err(error) => {
210                        if self.trace {
211                            eprintln!("[Transactions] Skipping unreadable log entry: {error}");
212                        }
213                    }
214                }
215            } else if let Some(rest) = entry.strip_prefix(COMMIT_MARKER_PREFIX) {
216                if let Ok(tx_id) = u128::from_str_radix(rest, 16) {
217                    self.committed.insert(tx_id);
218                }
219            } else if let Some(rest) = entry.strip_prefix(ROLLBACK_MARKER_PREFIX) {
220                if let Ok(tx_id) = u128::from_str_radix(rest, 16) {
221                    self.rolled_back.insert(tx_id);
222                }
223            } else if let Some(rest) = entry.strip_prefix(APPLIED_MARKER_PREFIX) {
224                if let Ok(seq) = rest.parse::<i64>() {
225                    self.applied.insert(seq);
226                    if seq > self.applied_sequence {
227                        self.applied_sequence = seq;
228                    }
229                }
230            }
231        }
232
233        // Re-apply committed-but-not-applied transitions (crash mid-async).
234        let log_snapshot: Vec<GenericTransition<T>> = self.log.clone();
235        self.replaying = true;
236        for transition in &log_snapshot {
237            if !self.committed.contains(&transition.transaction_id) {
238                continue;
239            }
240            if self.applied.contains(&transition.sequence) {
241                continue;
242            }
243            self.try_apply_transition(transition, true);
244        }
245        // Auto-rollback transitions written but never committed and never rolled back (R10).
246        let mut pending_tx_ids: Vec<u128> = Vec::new();
247        for transition in log_snapshot.iter().rev() {
248            if self.committed.contains(&transition.transaction_id) {
249                continue;
250            }
251            if self.rolled_back.contains(&transition.transaction_id) {
252                continue;
253            }
254            self.try_revert_transition(transition);
255            if !pending_tx_ids.contains(&transition.transaction_id) {
256                pending_tx_ids.push(transition.transaction_id);
257            }
258        }
259        self.replaying = false;
260        for tx_id in pending_tx_ids {
261            self.rolled_back.insert(tx_id);
262            self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{tx_id:032x}"))?;
263        }
264        Ok(())
265    }
266
267    pub(super) fn enforce_retention(&mut self) -> Result<(), LinkError> {
268        match self.retention_policy.clone() {
269            LogRetentionPolicy::Infinite => Ok(()),
270            LogRetentionPolicy::Sized { max_transitions } => self.enforce_sized(max_transitions),
271            LogRetentionPolicy::Chunked {
272                chunk_size,
273                archive_directory,
274            } => self.enforce_chunked(chunk_size, &archive_directory),
275        }
276    }
277
278    fn enforce_sized(&mut self, max_transitions: u64) -> Result<(), LinkError> {
279        if max_transitions == 0 {
280            return Ok(());
281        }
282        while self.log.len() as u64 > max_transitions {
283            let head = self.log[0];
284            if !self.applied.contains(&head.sequence) {
285                self.replaying = true;
286                self.try_apply_transition(&head, true);
287                self.replaying = false;
288                if !self.applied.contains(&head.sequence) {
289                    break; // R7: never drop an un-applied transition.
290                }
291            }
292            self.log.remove(0);
293            if self.trace {
294                eprintln!(
295                    "[Transactions] Dropped applied transition seq={} per sized retention.",
296                    head.sequence
297                );
298            }
299        }
300        Ok(())
301    }
302
303    fn enforce_chunked(
304        &mut self,
305        chunk_size: u64,
306        archive_directory: &Path,
307    ) -> Result<(), LinkError> {
308        if chunk_size == 0 {
309            return Ok(());
310        }
311        if (self.log.len() as u64) < chunk_size {
312            return Ok(());
313        }
314        let chunk: Vec<GenericTransition<T>> =
315            self.log.iter().take(chunk_size as usize).copied().collect();
316        for transition in &chunk {
317            if !self.applied.contains(&transition.sequence) {
318                self.replaying = true;
319                self.try_apply_transition(transition, true);
320                self.replaying = false;
321                if !self.applied.contains(&transition.sequence) {
322                    return Ok(()); // never drop un-applied
323                }
324            }
325        }
326        std::fs::create_dir_all(archive_directory).map_err(|error| {
327            LinkError::StorageError(format!(
328                "failed to create archive dir {}: {error}",
329                archive_directory.display()
330            ))
331        })?;
332        let timestamp = now_unix_ms();
333        let file_name = format!(
334            "transitions-chunk-{timestamp}-{:032x}.log",
335            new_transaction_id()
336        );
337        let path = archive_directory.join(file_name);
338        use std::io::Write;
339        let mut file = std::fs::File::create(&path).map_err(|error| {
340            LinkError::StorageError(format!(
341                "failed to create archive file {}: {error}",
342                path.display()
343            ))
344        })?;
345        for transition in &chunk {
346            writeln!(file, "{}", transition.serialize())?;
347        }
348        file.flush()?;
349        if self.trace {
350            eprintln!(
351                "[Transactions] Archived {} transitions to {}.",
352                chunk.len(),
353                path.display()
354            );
355        }
356        self.log.drain(0..chunk.len());
357        Ok(())
358    }
359}