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;