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;