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;