Skip to main content

git_policy_cli/
recovery_scan.rs

1//! What:
2//!  Startup recovery across every per-transaction directory of one worktree.
3//! Why:
4//!  Live owners are skipped;
5//!  dead owners are recovered oldest first;
6//!  dead transactions that
7//!      entered the landing critical section are recovered only while holding the landing lock,
8//!      so recovery never races a live lander (`src/policy-engine/commit-transaction-recovery-scan.ts`,
9//!      `commit-landing-lock.ts`).
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const outcomes = await recoverRegisteredTransactions({ root, gitPath, effectiveCwd });
14//! ```
15
16/// Debug diagnostics.
17use super::diagnostic_log::debug;
18/// Owner locks.
19use super::live_lock::acquire_live_owner_lock;
20use super::owner_lock::{OwnerLock, retire_lock_of_dead_owner};
21/// The fail-closed recovery failure.
22use super::recovery_error::{RecoveryError, io_failure};
23/// Landing evidence.
24use super::recovery_evidence::entered_landing;
25/// Owner inspection and order.
26use super::recovery_inspect::{
27    InspectedEntry, OwnerEvidence, inspect_registry_entry, oldest_first,
28};
29/// Dead-transaction recovery.
30use super::recovery_landing::{RecoveryAction, RecoveryPlace, recover_dead_transaction};
31/// Owner liveness.
32use super::transaction_owner::OwnerLiveness;
33/// Registry listing and names.
34use super::transaction_registry::{
35    EntryKind, LANDING_LOCK_NAME, RESERVATION_LOCK_NAME, RegistryEntry, list_transaction_entries,
36};
37/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
38use std::path::{Path, PathBuf};
39
40/// What:
41///  What recovery did for one directory.
42/// Why:
43///   `CommitTransactionRecoveryOutcome`.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type CommitTransactionRecoveryOutcome = { directory: string; action: CommitTransactionRecoveryAction };
48/// ```
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct RecoveryOutcome {
51    /// The directory examined.
52    pub directory: PathBuf,
53    /// What recovery did,
54    ///  or why it left the directory.
55    pub action: RecoveryAction,
56}
57
58/// What:
59///  Recover or skip one inspected entry without a landing record,
60///  or one whose landing the
61///       caller serializes.
62/// Why:
63///   `recoverInspectedEntry`.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// async function recoverInspectedEntry({ inspected, gitPath, effectiveCwd }): Promise<CommitTransactionRecoveryAction>;
68/// ```
69pub fn recover_inspected_entry(
70    place: &RecoveryPlace,
71    inspected: &InspectedEntry,
72) -> Result<RecoveryAction, RecoveryError> {
73    let path: &Path = inspected.entry.path.as_path();
74    match &inspected.owner {
75        OwnerEvidence::Vanished => {
76            debug(
77                "recoverInspectedEntry",
78                format!(
79                    "transaction finished while recovery listed it: {}",
80                    path.display()
81                )
82                .as_str(),
83            );
84            return Ok(RecoveryAction::Vanished);
85        }
86        OwnerEvidence::Unattributed => {
87            debug(
88                "recoverInspectedEntry",
89                format!(
90                    "staging candidate without a complete owner record left in place: {}",
91                    path.display()
92                )
93                .as_str(),
94            );
95            return Ok(RecoveryAction::StagingUnattributed);
96        }
97        OwnerEvidence::Owned(record, OwnerLiveness::Alive) => {
98            debug(
99                "recoverInspectedEntry",
100                format!(
101                    "transaction owner {} is active; skipping {}",
102                    record.owner_pid,
103                    path.display()
104                )
105                .as_str(),
106            );
107            return Ok(RecoveryAction::OwnerActive);
108        }
109        OwnerEvidence::Owned(record, OwnerLiveness::Dead) => {
110            if inspected.entry.kind == EntryKind::Staging {
111                // An unpublished candidate never started capture, so it touched nothing outside
112                // itself and remains for diagnosis.
113                debug(
114                    "recoverInspectedEntry",
115                    format!(
116                        "dead owner stopped before publishing; staging candidate kept: {}",
117                        path.display()
118                    )
119                    .as_str(),
120                );
121                return Ok(RecoveryAction::StagingRetained);
122            }
123            return recover_dead_transaction_unless_vanished(
124                place,
125                path,
126                inspected.entry.transaction_id.as_str(),
127                record.owner_pid,
128            );
129        }
130    }
131}
132
133/// What:
134///  `recover_dead_transaction`, answering "vanished" when the failure came from a transaction directory that is gone.
135/// Why:
136///   Another recoverer of the same dead transaction may finish and remove it after this recoverer inspected it;
137///  the incumbent treats a directory that disappeared between listing and reading as finished,
138///  whereas a failure with the directory still present is real.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// try { return await recoverDeadTransaction(request); } catch (e) { if (!(await pathExists(directory))) return 'vanished'; throw e; }
143/// ```
144fn recover_dead_transaction_unless_vanished(
145    place: &RecoveryPlace,
146    directory: &Path,
147    transaction_id: &str,
148    owner_pid: i64,
149) -> Result<RecoveryAction, RecoveryError> {
150    match recover_dead_transaction(place, directory, transaction_id, owner_pid) {
151        Ok(action) => return Ok(action),
152        Err(error) => {
153            if directory.exists() {
154                return Err(error);
155            }
156            debug(
157                "recoverInspectedEntry",
158                format!("transaction finished while recovery recovered it: {}", directory.display()).as_str(),
159            );
160            return Ok(RecoveryAction::Vanished);
161        }
162    }
163}
164
165/// What:
166///  Whether an inspected entry is a dead published transaction that entered the landing critical section:
167///  it holds a landing record or the record of a real `index.lock` it created.
168/// Why:
169///   `needsLandingLock` keyed on the landing record alone, so a transaction killed holding the landing lock and
170///       `index.lock` (an index-lock record, no landing record yet) was recovered without the landing lock.
171///  Another recoverer under the lock could release that `index.lock` and a lander could create a new one at the
172///       same path before this late release deleted it.
173///  `entered_landing` is the predicate the landing pass already uses,
174///  and the landing pass is documented as the only recovery that may release such a lock.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// async function needsLandingLock(inspected: InspectedEntry): Promise<boolean>;
179/// ```
180fn needs_landing_lock(inspected: &InspectedEntry) -> Result<bool, RecoveryError> {
181    if inspected.entry.kind != EntryKind::Transaction || inspected.dead_owner().is_none() {
182        return Ok(false);
183    }
184    return entered_landing_unless_vanished(inspected.entry.path.as_path());
185}
186
187/// What:
188///  `entered_landing`, answering "no" for a transaction directory that is gone.
189/// Why:
190///   Another recoverer of the same dead transaction may finish and remove it between this recoverer's registry listing
191///       and this check; a transaction that no longer exists has nothing to recover,
192///  whereas a directory that exists but cannot be listed stays an error.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// try { return await enteredLanding(directory); } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
197/// ```
198fn entered_landing_unless_vanished(directory: &Path) -> Result<bool, RecoveryError> {
199    match entered_landing(directory) {
200        Ok(entered) => return Ok(entered),
201        Err(error) => {
202            if directory.exists() {
203                return Err(error);
204            }
205            debug(
206                "recoverRegisteredTransactions",
207                format!("transaction finished while recovery listed it: {}", directory.display()).as_str(),
208            );
209            return Ok(false);
210        }
211    }
212}
213
214/// What:
215///  Recover every dead published transaction that entered the landing critical section;
216///  the
217///       caller holds the landing lock.
218/// Why:
219///   `recoverDeadLandings`:
220///  an owner killed while holding the landing lock and `index.lock`
221///       has no landing record yet,
222///  and its `index.lock` would block the next landing.
223///
224/// In TS you'd write (pseudocode):
225/// ```ts
226/// async function recoverDeadLandings({ root, gitPath, effectiveCwd }): Promise<readonly CommitTransactionRecoveryOutcome[]>;
227/// ```
228pub fn recover_dead_landings(
229    place: &RecoveryPlace,
230    root: &Path,
231) -> Result<Vec<RecoveryOutcome>, RecoveryError> {
232    let mut inspected: Vec<InspectedEntry> = Vec::new();
233    for entry in list_transaction_entries(root)? {
234        if entry.kind == EntryKind::Transaction {
235            inspected.push(inspect_registry_entry(&entry)?);
236        }
237    }
238    // `mut` allows collecting outcomes in recovery order.
239    let mut outcomes: Vec<RecoveryOutcome> = Vec::new();
240    for item in oldest_first(inspected) {
241        let Some(record) = item.dead_owner() else {
242            continue;
243        };
244        if !entered_landing_unless_vanished(item.entry.path.as_path())? {
245            continue;
246        }
247        debug(
248            "recoverDeadLandings",
249            format!("recovering dead landing {}", item.entry.path.display()).as_str(),
250        );
251        let action: RecoveryAction = recover_dead_transaction_unless_vanished(
252            place,
253            item.entry.path.as_path(),
254            item.entry.transaction_id.as_str(),
255            record.owner_pid,
256        )?;
257        outcomes.push(RecoveryOutcome {
258            directory: item.entry.path.clone(),
259            action,
260        });
261    }
262    return Ok(outcomes);
263}
264
265/// What:
266///  Take the landing lock,
267///  waiting while a live owner holds it.
268/// Why:
269///   Recovery of a landing must never race a live lander.
270///
271/// In TS you'd write (pseudocode):
272/// ```ts
273/// await acquireOwnerLock({ lockDirectory: join(root, LANDING_LOCK_NAME) })
274/// ```
275pub fn acquire_registry_lock(root: &Path, name: &str) -> Result<OwnerLock, RecoveryError> {
276    let directory: PathBuf = root.join(name);
277    match acquire_live_owner_lock(directory.as_path(), "a transaction registry lock") {
278        Ok(lock) => return Ok(lock),
279        Err(error) => return Err(RecoveryError(error.to_string())),
280    }
281}
282
283/// What:
284///  Whether an outcome already names a directory.
285/// Why:
286///   The landing pass may have recovered an entry the main pass would visit again.
287///
288/// In TS you'd write (pseudocode):
289/// ```ts
290/// outcomes.some((outcome) => outcome.directory === path)
291/// ```
292fn recovered_already(outcomes: &[RecoveryOutcome], path: &Path) -> bool {
293    for outcome in outcomes {
294        if outcome.directory == path {
295            return true;
296        }
297    }
298    return false;
299}
300
301/// What:
302///  Whether the landing pass owns an entry:
303///  it was known to need the landing lock, or the pass has already recovered it.
304/// Why:
305///   An entry whose owner died after the first inspection is recovered by the landing pass although it did not need
306///  the lock then, and one that vanished before the pass is not recovered at all;
307///  in both cases the main pass must leave it alone instead of acting on its stale inspection.
308///
309/// In TS you'd write (pseudocode):
310/// ```ts
311/// needed || outcomes.some((outcome) => outcome.directory === path)
312/// ```
313fn handled_by_the_landing_pass(needed: bool, outcomes: &[RecoveryOutcome], path: &Path) -> bool {
314    return needed || recovered_already(outcomes, path);
315}
316
317/// What:
318///  Recover every dead-owner transaction of one worktree registry and remove retired
319///       leftovers;
320///  a dead reservation holder's lock is retired too.
321/// Why:
322///   `recoverRegisteredTransactions`.
323///  Unlike the incumbent,
324///  an entry the landing pass already
325///       recovered is not recovered a second time (the incumbent would fail reading its removed
326///       directory;
327///  recorded in `doc/handover/cli-git-native-transactions.md`).
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// async function recoverRegisteredTransactions({ root, gitPath, effectiveCwd }): Promise<readonly CommitTransactionRecoveryOutcome[]>;
332/// ```
333pub fn recover_registered_transactions(
334    place: &RecoveryPlace,
335    root: &Path,
336) -> Result<Vec<RecoveryOutcome>, RecoveryError> {
337    let entries: Vec<RegistryEntry> = list_transaction_entries(root)?;
338    let mut inspected: Vec<InspectedEntry> = Vec::new();
339    let mut retired: Vec<PathBuf> = Vec::new();
340    for entry in &entries {
341        if entry.kind == EntryKind::Retired {
342            retired.push(entry.path.clone());
343        } else {
344            inspected.push(inspect_registry_entry(entry)?);
345        }
346    }
347    let ordered: Vec<InspectedEntry> = oldest_first(inspected);
348    let mut needs: Vec<bool> = Vec::with_capacity(ordered.len());
349    for item in &ordered {
350        needs.push(needs_landing_lock(item)?);
351    }
352    let mut outcomes: Vec<RecoveryOutcome> = Vec::new();
353    if needs.contains(&true) {
354        let lock: OwnerLock = acquire_registry_lock(root, LANDING_LOCK_NAME)?;
355        let recovered: Result<Vec<RecoveryOutcome>, RecoveryError> =
356            recover_dead_landings(place, root);
357        let released: Result<(), super::owner_lock::OwnerLockError> = lock.release();
358        outcomes.extend(recovered?);
359        if let Err(error) = released {
360            return Err(RecoveryError(error.to_string()));
361        }
362    }
363    for (index, item) in ordered.iter().enumerate() {
364        if handled_by_the_landing_pass(needs[index], outcomes.as_slice(), item.entry.path.as_path()) {
365            continue;
366        }
367        let action: RecoveryAction = recover_inspected_entry(place, item)?;
368        outcomes.push(RecoveryOutcome {
369            directory: item.entry.path.clone(),
370            action,
371        });
372    }
373    for path in &retired {
374        if let Err(error) = super::private_storage::remove_tree(path.as_path()) {
375            return Err(io_failure(
376                "removing the retired transaction",
377                path.as_path(),
378                &error,
379            ));
380        }
381    }
382    // A crashed reservation holder's lock is retired here as well as by the next requester.
383    if let Err(error) = retire_lock_of_dead_owner(root.join(RESERVATION_LOCK_NAME).as_path()) {
384        return Err(RecoveryError(error.to_string()));
385    }
386    for path in retired {
387        outcomes.push(RecoveryOutcome {
388            directory: path,
389            action: RecoveryAction::RetiredRemoved,
390        });
391    }
392    return Ok(outcomes);
393}
394
395/// Registry recovery controls stay out of the release executable.
396#[cfg(test)]
397#[path = "recovery_scan_tests.rs"]
398mod tests;