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