git recover: rescue an import that stopped before it finished 54e2e6309aa3
An import writes every object into one batch pack indexed only at the end, and moves its bookmark only at the end, so a stopped import left everything unreadable. Store::adopt_unfinished_packs reads such a pack through (untouched for five minutes), checks each entry against its reference, cuts a torn last one off and indexes it; oxy git recover then points the bookmark at every change carrying a Git-Commit trailer, and oxy git fetch carries on. Checked: an import killed after 2474 of 3000 commits, recovered, then fetched: 3000 commits verified. closes #79
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-06 18:50 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
54e2e6309aa32691f0029cc15aa75136d011d0f614cf128f60d1431e360712e7- Depends on
- 1983686fb7a7 30ea9c1e70fa 62794ecc7f74 75167672ebcd 79ab8528ff62 9935d490ddfb a9f686edf60b c3069537d7c5 d02e545dc265 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
- Depended on by
- d6e0d5b8b339
- In bookmarks
- bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.10.0 v0.11.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 8 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/repo/git.rs +60 −0
| … | 1383 unchanged lines |
||
| 1384 | 1384 | Ok(()) |
|
| 1385 | 1385 | } |
|
| 1386 | 1386 | ||
| 1387 | + | /// Recover an import that stopped before it moved the bookmark: its |
|
| 1388 | + | /// unfinished pack indexed (`Store::adopt_unfinished_packs`), then every |
|
| 1389 | + | /// change in the store that carries a `Git-Commit:` trailer, its heads |
|
| 1390 | + | /// (those no other such change depends on) made the bookmark's heads, |
|
| 1391 | + | /// so `oxy git fetch` carries on from there. Reads every object once. |
|
| 1392 | + | /// Returns how many imported changes it found and the heads. |
|
| 1393 | + | pub fn recover_git_import(&self, progress: &dyn Fn(&str)) -> Result<(usize, usize)> { |
|
| 1394 | + | // A stopped import's objects are in a pack it never indexed. |
|
| 1395 | + | // OXY_RECOVER_MIN_AGE (seconds) overrides the five minutes it must |
|
| 1396 | + | // have been left alone, for testing. |
|
| 1397 | + | let min_age = std::env::var("OXY_RECOVER_MIN_AGE") |
|
| 1398 | + | .ok() |
|
| 1399 | + | .and_then(|v| v.parse().ok()) |
|
| 1400 | + | .unwrap_or(300); |
|
| 1401 | + | self.store |
|
| 1402 | + | .adopt_unfinished_packs(std::time::Duration::from_secs(min_age), progress)?; |
|
| 1403 | + | let all = self |
|
| 1404 | + | .store |
|
| 1405 | + | .list() |
|
| 1406 | + | .map_err(|e| RepoError::Msg(format!("listing the store: {e}")))?; |
|
| 1407 | + | let total = all.len(); |
|
| 1408 | + | let mut found: HashMap<ChangeHash, Vec<ChangeHash>> = HashMap::new(); |
|
| 1409 | + | for (i, r) in all.iter().enumerate() { |
|
| 1410 | + | if i.is_multiple_of(100_000) { |
|
| 1411 | + | progress(&format!( |
|
| 1412 | + | "{i} of {total} objects read, {} imported changes", |
|
| 1413 | + | found.len() |
|
| 1414 | + | )); |
|
| 1415 | + | } |
|
| 1416 | + | let Ok(meta) = self.store.get_change_meta(r) else { |
|
| 1417 | + | continue; |
|
| 1418 | + | }; |
|
| 1419 | + | if meta.message.lines().any(|l| l.starts_with("Git-Commit: ")) { |
|
| 1420 | + | found.insert(*r, meta.deps.clone()); |
|
| 1421 | + | } |
|
| 1422 | + | } |
|
| 1423 | + | if found.is_empty() { |
|
| 1424 | + | return fail("no imported Git commit's change in this store"); |
|
| 1425 | + | } |
|
| 1426 | + | let depended: HashSet<ChangeHash> = found.values().flatten().copied().collect(); |
|
| 1427 | + | let mut heads: Vec<ChangeHash> = found |
|
| 1428 | + | .keys() |
|
| 1429 | + | .filter(|h| !depended.contains(h)) |
|
| 1430 | + | .copied() |
|
| 1431 | + | .collect(); |
|
| 1432 | + | // The bookmark's present heads stay: a recovery only adds. |
|
| 1433 | + | heads.extend(self.bookmark_heads().unwrap_or_default()); |
|
| 1434 | + | let st = self.load_state_history(&heads)?; |
|
| 1435 | + | let mut heads = st.maximal(&heads); |
|
| 1436 | + | heads.sort(); |
|
| 1437 | + | heads.dedup(); |
|
| 1438 | + | let (op_ref, op) = self.head_op()?; |
|
| 1439 | + | let mut view = op.view.clone(); |
|
| 1440 | + | let state_ref = self.store.put_state(&StateObject::new(heads.clone()))?; |
|
| 1441 | + | view.bookmarks |
|
| 1442 | + | .insert(self.config.bookmark.clone(), state_ref); |
|
| 1443 | + | self.record_op(&[op_ref], "oxy git recover", view)?; |
|
| 1444 | + | Ok((found.len(), heads.len())) |
|
| 1445 | + | } |
|
| 1446 | + | ||
| 1387 | 1447 | /// Point the bookmark at an import's heads so far (see |
|
| 1388 | 1448 | /// `CHECKPOINT_COMMITS`); the working copy is left alone until the end. |
|
| 1389 | 1449 | fn checkpoint_import(&self, heads: &BTreeSet<ChangeHash>, done: usize) -> Result<()> { |
|
| … | 419 unchanged lines |
modified crates/oxy-core/src/store.rs +154 −0
| … | 1418 unchanged lines |
||
| 1419 | 1419 | Ok(()) |
|
| 1420 | 1420 | } |
|
| 1421 | 1421 | ||
| 1422 | + | /// Finish packs a writer left unfinished: a batch writes its objects to |
|
| 1423 | + | /// `.tmp-ID.pack` and only indexes it at the end, so a process that |
|
| 1424 | + | /// stopped part way (an import killed after hours) leaves every object |
|
| 1425 | + | /// in a pack nothing can read. Each such pack untouched for `min_age` |
|
| 1426 | + | /// is read through: every whole entry checked against its reference, |
|
| 1427 | + | /// a torn last one cut off, then indexed as a pack of the store. |
|
| 1428 | + | /// Returns the packs and objects adopted. |
|
| 1429 | + | pub fn adopt_unfinished_packs( |
|
| 1430 | + | &self, |
|
| 1431 | + | min_age: std::time::Duration, |
|
| 1432 | + | progress: &dyn Fn(&str), |
|
| 1433 | + | ) -> Result<(usize, usize)> { |
|
| 1434 | + | use std::io::{BufReader, Read}; |
|
| 1435 | + | let mut packs = 0; |
|
| 1436 | + | let mut objects = 0; |
|
| 1437 | + | let Ok(dir) = fs::read_dir(&self.scratch) else { |
|
| 1438 | + | return Ok((0, 0)); |
|
| 1439 | + | }; |
|
| 1440 | + | for entry in dir.flatten() { |
|
| 1441 | + | let name = entry.file_name().to_string_lossy().to_string(); |
|
| 1442 | + | let Some(id) = name |
|
| 1443 | + | .strip_prefix(".tmp-") |
|
| 1444 | + | .and_then(|n| n.strip_suffix(".pack")) |
|
| 1445 | + | else { |
|
| 1446 | + | continue; |
|
| 1447 | + | }; |
|
| 1448 | + | let path = entry.path(); |
|
| 1449 | + | let meta = fs::metadata(&path)?; |
|
| 1450 | + | let age = meta |
|
| 1451 | + | .modified() |
|
| 1452 | + | .ok() |
|
| 1453 | + | .and_then(|m| m.elapsed().ok()) |
|
| 1454 | + | .unwrap_or_default(); |
|
| 1455 | + | if age < min_age { |
|
| 1456 | + | progress(&format!( |
|
| 1457 | + | "{name}: written to {}s ago, left alone", |
|
| 1458 | + | age.as_secs() |
|
| 1459 | + | )); |
|
| 1460 | + | continue; |
|
| 1461 | + | } |
|
| 1462 | + | let mut f = BufReader::with_capacity(1 << 20, fs::File::open(&path)?); |
|
| 1463 | + | let mut magic = [0u8; 8]; |
|
| 1464 | + | if f.read_exact(&mut magic).is_err() || &magic != PACK_MAGIC { |
|
| 1465 | + | progress(&format!("{name}: not a pack, left alone")); |
|
| 1466 | + | continue; |
|
| 1467 | + | } |
|
| 1468 | + | let mut offset = PACK_MAGIC.len() as u64; |
|
| 1469 | + | let mut index: Vec<(ObjectRef, PackEntry)> = Vec::new(); |
|
| 1470 | + | loop { |
|
| 1471 | + | let mut head = [0u8; 42]; |
|
| 1472 | + | if f.read_exact(&mut head).is_err() { |
|
| 1473 | + | break; |
|
| 1474 | + | } |
|
| 1475 | + | let Some(ty) = ObjectType::ALL.get(head[0] as usize).copied() else { |
|
| 1476 | + | break; |
|
| 1477 | + | }; |
|
| 1478 | + | let flags = head[1]; |
|
| 1479 | + | let stored_len = u32::from_le_bytes(head[2..6].try_into().unwrap()); |
|
| 1480 | + | let raw_len = u32::from_le_bytes(head[6..10].try_into().unwrap()); |
|
| 1481 | + | let r = ObjectRef(head[10..42].try_into().unwrap()); |
|
| 1482 | + | let mut stored = vec![0u8; stored_len as usize]; |
|
| 1483 | + | if flags & FLAG_DICT != 0 || f.read_exact(&mut stored).is_err() { |
|
| 1484 | + | break; |
|
| 1485 | + | } |
|
| 1486 | + | let e = PackEntry { |
|
| 1487 | + | offset: offset + 42, |
|
| 1488 | + | stored_len, |
|
| 1489 | + | raw_len, |
|
| 1490 | + | ty, |
|
| 1491 | + | flags, |
|
| 1492 | + | }; |
|
| 1493 | + | if decode_entry(&r, e, stored, None).is_err() { |
|
| 1494 | + | break; |
|
| 1495 | + | } |
|
| 1496 | + | index.push((r, e)); |
|
| 1497 | + | offset += 42 + stored_len as u64; |
|
| 1498 | + | if index.len().is_multiple_of(200_000) { |
|
| 1499 | + | progress(&format!( |
|
| 1500 | + | "{name}: {} objects checked, {} MiB", |
|
| 1501 | + | index.len(), |
|
| 1502 | + | offset >> 20 |
|
| 1503 | + | )); |
|
| 1504 | + | } |
|
| 1505 | + | } |
|
| 1506 | + | if index.is_empty() { |
|
| 1507 | + | progress(&format!("{name}: no whole object, left alone")); |
|
| 1508 | + | continue; |
|
| 1509 | + | } |
|
| 1510 | + | if offset < meta.len() { |
|
| 1511 | + | progress(&format!( |
|
| 1512 | + | "{name}: {} torn bytes at the end cut off", |
|
| 1513 | + | meta.len() - offset |
|
| 1514 | + | )); |
|
| 1515 | + | fs::OpenOptions::new() |
|
| 1516 | + | .write(true) |
|
| 1517 | + | .open(&path)? |
|
| 1518 | + | .set_len(offset)?; |
|
| 1519 | + | } |
|
| 1520 | + | index.sort_by_key(|(r, _)| *r); |
|
| 1521 | + | index.dedup_by_key(|(r, _)| *r); |
|
| 1522 | + | objects += index.len(); |
|
| 1523 | + | packs += 1; |
|
| 1524 | + | progress(&format!("{name}: {} objects indexed", index.len())); |
|
| 1525 | + | self.finish_pack_files(id, &path, &index)?; |
|
| 1526 | + | } |
|
| 1527 | + | Ok((packs, objects)) |
|
| 1528 | + | } |
|
| 1529 | + | ||
| 1422 | 1530 | /// Every object reference in the store, loose and packed (slow; for gc and tests). |
|
| 1423 | 1531 | pub fn list(&self) -> io::Result<Vec<ObjectRef>> { |
|
| 1424 | 1532 | let mut out: Vec<ObjectRef> = self.loose_objects()?.into_iter().map(|(r, _)| r).collect(); |
|
| … | 493 unchanged lines |
||
| 1918 | 2026 | let again = Store::open(dir.path()).unwrap(); |
|
| 1919 | 2027 | for r in &refs { |
|
| 1920 | 2028 | again |
|
| 2029 | + | .get(r) |
|
| 2030 | + | .unwrap_or_else(|e| panic!("{}: {e}", r.short(12))); |
|
| 2031 | + | } |
|
| 2032 | + | } |
|
| 2033 | + | ||
| 2034 | + | #[test] |
|
| 2035 | + | fn a_pack_a_stopped_batch_left_is_adopted_and_its_torn_end_cut() { |
|
| 2036 | + | // oxygen#79: a batch never finished (a process killed part way) |
|
| 2037 | + | // leaves its objects in an unindexed .tmp pack. |
|
| 2038 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 2039 | + | let mut refs = Vec::new(); |
|
| 2040 | + | { |
|
| 2041 | + | let store = Store::open(dir.path()).unwrap(); |
|
| 2042 | + | let batch = store.begin_batch().unwrap(); |
|
| 2043 | + | for i in 0..300u32 { |
|
| 2044 | + | let body = format!("object {i} {}", "y".repeat(20_000 + i as usize)).into_bytes(); |
|
| 2045 | + | refs.push(store.put(ObjectType::Blob, &body).unwrap()); |
|
| 2046 | + | } |
|
| 2047 | + | std::mem::forget(batch); |
|
| 2048 | + | } |
|
| 2049 | + | let tmp: Vec<_> = fs::read_dir(dir.path()) |
|
| 2050 | + | .unwrap() |
|
| 2051 | + | .flatten() |
|
| 2052 | + | .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp-")) |
|
| 2053 | + | .map(|e| e.path()) |
|
| 2054 | + | .collect(); |
|
| 2055 | + | assert_eq!(tmp.len(), 1, "one unfinished pack"); |
|
| 2056 | + | // A torn last entry: a header and part of its body. |
|
| 2057 | + | let mut f = fs::OpenOptions::new().append(true).open(&tmp[0]).unwrap(); |
|
| 2058 | + | f.write_all(&[0, 1, 0xff, 0, 0, 0, 9, 9, 0, 0]).unwrap(); |
|
| 2059 | + | f.write_all(&[7; 40]).unwrap(); |
|
| 2060 | + | drop(f); |
|
| 2061 | + | let store = Store::open(dir.path()).unwrap(); |
|
| 2062 | + | assert!(store.get(&refs[0]).is_err(), "unreadable before"); |
|
| 2063 | + | let young = store |
|
| 2064 | + | .adopt_unfinished_packs(std::time::Duration::from_secs(3600), &|_| {}) |
|
| 2065 | + | .unwrap(); |
|
| 2066 | + | assert_eq!(young, (0, 0), "a pack written to lately is left alone"); |
|
| 2067 | + | let (packs, objects) = store |
|
| 2068 | + | .adopt_unfinished_packs(std::time::Duration::ZERO, &|_| {}) |
|
| 2069 | + | .unwrap(); |
|
| 2070 | + | assert_eq!((packs, objects), (1, 300)); |
|
| 2071 | + | for r in &refs { |
|
| 2072 | + | store |
|
| 1921 | 2073 | .get(r) |
|
| 1922 | 2074 | .unwrap_or_else(|e| panic!("{}: {e}", r.short(12))); |
|
| 1923 | 2075 | } |
|
| 2076 | + | let again = Store::open(dir.path()).unwrap(); |
|
| 2077 | + | assert!(again.get(&refs[299]).is_ok(), "indexed for good"); |
|
| 1924 | 2078 | } |
|
| 1925 | 2079 | } |
|
| 1926 | 2080 | ||
| … | 120 unchanged lines |
modified docs/guide.md +6 −0
| … | 540 unchanged lines |
||
| 541 | 541 | has been fetched, so it always fast-forwards. Exported commits carry |
|
| 542 | 542 | `Change-Id` and `Oxy-Hash` trailers. |
|
| 543 | 543 | ||
| 544 | + | A long import moves its bookmark to the commits done so far every 2000 |
|
| 545 | + | commits, so if it stops, `oxy git fetch GIT_DIR --branch BRANCH` in the new |
|
| 546 | + | repository carries on from there. One stopped before its first checkpoint |
|
| 547 | + | (or by an older oxy) is recovered first with `oxy git recover`, which |
|
| 548 | + | indexes the objects it had written and points the bookmark at them. |
|
| 549 | + | ||
| 544 | 550 | ## 13. Automate: hooks |
|
| 545 | 551 | ||
| 546 | 552 | Executables in `.oxy/hooks/<event>` (never cloned) receive one JSON object |
|
| … | 100 unchanged lines |
modified src/cli/mod.rs +5 −0
| … | 900 unchanged lines |
||
| 901 | 901 | #[arg(long, default_value = "main")] |
|
| 902 | 902 | branch: String, |
|
| 903 | 903 | }, |
|
| 904 | + | /// Recover an import that stopped before it moved the bookmark: point the bookmark at every imported commit's change in the store, so oxy git fetch carries on from there |
|
| 905 | + | Recover, |
|
| 904 | 906 | /// Check this repository's import of a Git branch: every commit's tree must be reproduced exactly |
|
| 905 | 907 | Verify { |
|
| 906 | 908 | /// The Git repository the import came from |
|
| … | 380 unchanged lines |
||
| 1287 | 1289 | cmd: GitCmd::Push { url, branch }, |
|
| 1288 | 1290 | } => sync::git_push(&url, &branch), |
|
| 1289 | 1291 | Cmd::Git { |
|
| 1292 | + | cmd: GitCmd::Recover, |
|
| 1293 | + | } => sync::git_recover(), |
|
| 1294 | + | Cmd::Git { |
|
| 1290 | 1295 | cmd: |
|
| 1291 | 1296 | GitCmd::Import { |
|
| 1292 | 1297 | git_dir, |
|
| … | 86 unchanged lines |
modified src/cli/sync.rs +19 −0
| … | 547 unchanged lines |
||
| 548 | 548 | Ok(0) |
|
| 549 | 549 | } |
|
| 550 | 550 | ||
| 551 | + | pub fn git_recover() -> Result<i32> { |
|
| 552 | + | let repo = open()?; |
|
| 553 | + | let sp = ui::spinner("Reading the store…"); |
|
| 554 | + | let (changes, heads) = repo.recover_git_import(&|m| sp.set(m))?; |
|
| 555 | + | sp.done(&format!( |
|
| 556 | + | "{} {} from imported Git commits: {} now has {}", |
|
| 557 | + | ui::done("Recovered"), |
|
| 558 | + | plural(changes, "change"), |
|
| 559 | + | ui::bookmark(&repo.config.bookmark), |
|
| 560 | + | plural(heads, "head") |
|
| 561 | + | )); |
|
| 562 | + | println!( |
|
| 563 | + | " {} {}", |
|
| 564 | + | ui::note(), |
|
| 565 | + | ui::dim("carry on with oxy git fetch GIT_DIR --branch BRANCH") |
|
| 566 | + | ); |
|
| 567 | + | Ok(0) |
|
| 568 | + | } |
|
| 569 | + | ||
| 551 | 570 | pub fn git_fetch(url: &str, branch: &str) -> Result<i32> { |
|
| 552 | 571 | let repo = open()?; |
|
| 553 | 572 | let sp = ui::spinner(&format!("Fetching {url} {branch}…")); |
|
| … | 317 unchanged lines |