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::owner_lock::{
20    DEFAULT_POLL_DELAY, OwnerLock, SilentWait, acquire_owner_lock, retire_lock_of_dead_owner,
21};
22/// The fail-closed recovery failure.
23use super::recovery_error::{RecoveryError, io_failure};
24/// Landing evidence.
25use super::recovery_evidence::{entered_landing, has_landing_record};
26/// Owner inspection and order.
27use super::recovery_inspect::{
28    InspectedEntry, OwnerEvidence, inspect_registry_entry, oldest_first,
29};
30/// Dead-transaction recovery.
31use super::recovery_landing::{RecoveryAction, RecoveryPlace, recover_dead_transaction};
32/// Owner liveness.
33use super::transaction_owner::OwnerLiveness;
34/// Registry listing and names.
35use super::transaction_registry::{
36    EntryKind, LANDING_LOCK_NAME, RESERVATION_LOCK_NAME, RegistryEntry, list_transaction_entries,
37};
38/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
39use std::path::{Path, PathBuf};
40
41/// What:
42///  What recovery did for one directory.
43/// Why:
44///   `CommitTransactionRecoveryOutcome`.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type CommitTransactionRecoveryOutcome = { directory: string; action: CommitTransactionRecoveryAction };
49/// ```
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct RecoveryOutcome {
52    /// The directory examined.
53    pub directory: PathBuf,
54    /// What recovery did,
55    ///  or why it left the directory.
56    pub action: RecoveryAction,
57}
58
59/// What:
60///  Recover or skip one inspected entry without a landing record,
61///  or one whose landing the
62///       caller serializes.
63/// Why:
64///   `recoverInspectedEntry`.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// async function recoverInspectedEntry({ inspected, gitPath, effectiveCwd }): Promise<CommitTransactionRecoveryAction>;
69/// ```
70pub fn recover_inspected_entry(
71    place: &RecoveryPlace,
72    inspected: &InspectedEntry,
73) -> Result<RecoveryAction, RecoveryError> {
74    let path: &Path = inspected.entry.path.as_path();
75    match &inspected.owner {
76        OwnerEvidence::Vanished => {
77            debug(
78                "recoverInspectedEntry",
79                format!(
80                    "transaction finished while recovery listed it: {}",
81                    path.display()
82                )
83                .as_str(),
84            );
85            return Ok(RecoveryAction::Vanished);
86        }
87        OwnerEvidence::Unattributed => {
88            debug(
89                "recoverInspectedEntry",
90                format!(
91                    "staging candidate without a complete owner record left in place: {}",
92                    path.display()
93                )
94                .as_str(),
95            );
96            return Ok(RecoveryAction::StagingUnattributed);
97        }
98        OwnerEvidence::Owned(record, OwnerLiveness::Alive) => {
99            debug(
100                "recoverInspectedEntry",
101                format!(
102                    "transaction owner {} is active; skipping {}",
103                    record.owner_pid,
104                    path.display()
105                )
106                .as_str(),
107            );
108            return Ok(RecoveryAction::OwnerActive);
109        }
110        OwnerEvidence::Owned(record, OwnerLiveness::Dead) => {
111            if inspected.entry.kind == EntryKind::Staging {
112                // An unpublished candidate never started capture, so it touched nothing outside
113                // itself and remains for diagnosis.
114                debug(
115                    "recoverInspectedEntry",
116                    format!(
117                        "dead owner stopped before publishing; staging candidate kept: {}",
118                        path.display()
119                    )
120                    .as_str(),
121                );
122                return Ok(RecoveryAction::StagingRetained);
123            }
124            return recover_dead_transaction(
125                place,
126                path,
127                inspected.entry.transaction_id.as_str(),
128                record.owner_pid,
129            );
130        }
131    }
132}
133
134/// What:
135///  Whether an inspected entry is a dead published transaction holding a landing record.
136/// Why:
137///   `needsLandingLock`.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// async function needsLandingLock(inspected: InspectedEntry): Promise<boolean>;
142/// ```
143fn needs_landing_lock(inspected: &InspectedEntry) -> Result<bool, RecoveryError> {
144    if inspected.entry.kind != EntryKind::Transaction || inspected.dead_owner().is_none() {
145        return Ok(false);
146    }
147    return has_landing_record(inspected.entry.path.as_path());
148}
149
150/// What:
151///  Recover every dead published transaction that entered the landing critical section;
152///  the
153///       caller holds the landing lock.
154/// Why:
155///   `recoverDeadLandings`:
156///  an owner killed while holding the landing lock and `index.lock`
157///       has no landing record yet,
158///  and its `index.lock` would block the next landing.
159///
160/// In TS you'd write (pseudocode):
161/// ```ts
162/// async function recoverDeadLandings({ root, gitPath, effectiveCwd }): Promise<readonly CommitTransactionRecoveryOutcome[]>;
163/// ```
164pub fn recover_dead_landings(
165    place: &RecoveryPlace,
166    root: &Path,
167) -> Result<Vec<RecoveryOutcome>, RecoveryError> {
168    let mut inspected: Vec<InspectedEntry> = Vec::new();
169    for entry in list_transaction_entries(root)? {
170        if entry.kind == EntryKind::Transaction {
171            inspected.push(inspect_registry_entry(&entry)?);
172        }
173    }
174    // `mut` allows collecting outcomes in recovery order.
175    let mut outcomes: Vec<RecoveryOutcome> = Vec::new();
176    for item in oldest_first(inspected) {
177        let Some(record) = item.dead_owner() else {
178            continue;
179        };
180        if !entered_landing(item.entry.path.as_path())? {
181            continue;
182        }
183        debug(
184            "recoverDeadLandings",
185            format!("recovering dead landing {}", item.entry.path.display()).as_str(),
186        );
187        let action: RecoveryAction = recover_dead_transaction(
188            place,
189            item.entry.path.as_path(),
190            item.entry.transaction_id.as_str(),
191            record.owner_pid,
192        )?;
193        outcomes.push(RecoveryOutcome {
194            directory: item.entry.path.clone(),
195            action,
196        });
197    }
198    return Ok(outcomes);
199}
200
201/// What:
202///  Take the landing lock,
203///  waiting while a live owner holds it.
204/// Why:
205///   Recovery of a landing must never race a live lander.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// await acquireOwnerLock({ lockDirectory: join(root, LANDING_LOCK_NAME) })
210/// ```
211pub fn acquire_registry_lock(root: &Path, name: &str) -> Result<OwnerLock, RecoveryError> {
212    let directory: PathBuf = root.join(name);
213    // `mut` is required by the notice interface; this wait is silent, as the incumbent's.
214    let mut silent: SilentWait = SilentWait;
215    match acquire_owner_lock(directory.as_path(), DEFAULT_POLL_DELAY, &mut silent) {
216        Ok(lock) => return Ok(lock),
217        Err(error) => return Err(RecoveryError(error.to_string())),
218    }
219}
220
221/// What:
222///  Whether an outcome already names a directory.
223/// Why:
224///   The landing pass may have recovered an entry the main pass would visit again.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// outcomes.some((outcome) => outcome.directory === path)
229/// ```
230fn recovered_already(outcomes: &[RecoveryOutcome], path: &Path) -> bool {
231    for outcome in outcomes {
232        if outcome.directory == path {
233            return true;
234        }
235    }
236    return false;
237}
238
239/// What:
240///  Recover every dead-owner transaction of one worktree registry and remove retired
241///       leftovers;
242///  a dead reservation holder's lock is retired too.
243/// Why:
244///   `recoverRegisteredTransactions`.
245///  Unlike the incumbent,
246///  an entry the landing pass already
247///       recovered is not recovered a second time (the incumbent would fail reading its removed
248///       directory;
249///  recorded in `doc/handover/cli-git-native-transactions.md`).
250///
251/// In TS you'd write (pseudocode):
252/// ```ts
253/// async function recoverRegisteredTransactions({ root, gitPath, effectiveCwd }): Promise<readonly CommitTransactionRecoveryOutcome[]>;
254/// ```
255pub fn recover_registered_transactions(
256    place: &RecoveryPlace,
257    root: &Path,
258) -> Result<Vec<RecoveryOutcome>, RecoveryError> {
259    let entries: Vec<RegistryEntry> = list_transaction_entries(root)?;
260    let mut inspected: Vec<InspectedEntry> = Vec::new();
261    let mut retired: Vec<PathBuf> = Vec::new();
262    for entry in &entries {
263        if entry.kind == EntryKind::Retired {
264            retired.push(entry.path.clone());
265        } else {
266            inspected.push(inspect_registry_entry(entry)?);
267        }
268    }
269    let ordered: Vec<InspectedEntry> = oldest_first(inspected);
270    let mut needs: Vec<bool> = Vec::with_capacity(ordered.len());
271    for item in &ordered {
272        needs.push(needs_landing_lock(item)?);
273    }
274    let mut outcomes: Vec<RecoveryOutcome> = Vec::new();
275    if needs.contains(&true) {
276        let lock: OwnerLock = acquire_registry_lock(root, LANDING_LOCK_NAME)?;
277        let recovered: Result<Vec<RecoveryOutcome>, RecoveryError> =
278            recover_dead_landings(place, root);
279        let released: Result<(), super::owner_lock::OwnerLockError> = lock.release();
280        outcomes.extend(recovered?);
281        if let Err(error) = released {
282            return Err(RecoveryError(error.to_string()));
283        }
284    }
285    for (index, item) in ordered.iter().enumerate() {
286        if needs[index] || recovered_already(outcomes.as_slice(), item.entry.path.as_path()) {
287            continue;
288        }
289        let action: RecoveryAction = recover_inspected_entry(place, item)?;
290        outcomes.push(RecoveryOutcome {
291            directory: item.entry.path.clone(),
292            action,
293        });
294    }
295    for path in &retired {
296        if let Err(error) = super::private_storage::remove_tree(path.as_path()) {
297            return Err(io_failure(
298                "removing the retired transaction",
299                path.as_path(),
300                &error,
301            ));
302        }
303    }
304    // A crashed reservation holder's lock is retired here as well as by the next requester.
305    if let Err(error) = retire_lock_of_dead_owner(root.join(RESERVATION_LOCK_NAME).as_path()) {
306        return Err(RecoveryError(error.to_string()));
307    }
308    for path in retired {
309        outcomes.push(RecoveryOutcome {
310            directory: path,
311            action: RecoveryAction::RetiredRemoved,
312        });
313    }
314    return Ok(outcomes);
315}
316
317/// Registry recovery controls stay out of the release executable.
318#[cfg(test)]
319#[path = "recovery_scan_tests.rs"]
320mod tests;