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