OXYGENHUB

Strata: a push lands over what landed meanwhile; oxy gc --remote d8d9367878a6

When the record moved between a push's read and its compare-and-swap,
the mirror is put back, brought level, and the push's updates applied
over what landed, up to four times: two pushes to different bookmarks
both get through, and one to the same bookmark is told it is stale,
as a hub says. A bundle a sync listed and then finds gone is skipped,
and a bundle whose dependencies arrive after it is tried again (the
deferral compared against a drained list and never retried).

oxy gc --remote NAME folds a store remote's bundles into one holding
what the record reaches, and names the superseded ones in a gc record
with the time; they are removed only once seven days have passed
(--now ignores that), so a reader that listed them can still fetch
them.

refs #63
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-03 23:08 UTC
Signature
✔ signed by Andy McCall
Hash
d8d9367878a60725d141ff26bc6bbeb473a54db27604561408404e088ecabd36
Depends on
252e8cb23fbc 387335ff7f4d 3e5772ccf9da 614541c3cebd 9aa399044d9d 9f9e72b93e4e a70dce536476 ba160f60219e fce44ae9ad29
Depended on by
b689779e9e74
In bookmarks
agents-in-readme 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.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
22 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/oxy-core/src/repo/strata.rs +535 −46
…
268 unchanged lines
269 269
        while !pending.is_empty() {
270 270
            let mut deferred = Vec::new();
271 271
            let mut last_error = None;
272 +
            let before = pending.len();
272 273
            for key in pending.drain(..) {
273 -
                let bytes = self.store.get(&key)?;
274 +
                let bytes = match self.store.get(&key) {
275 +
                    Ok(b) => b,
276 +
                    // Listed, then gone: a gc merged it into a newer
277 +
                    // bundle, which this listing holds too.
278 +
                    Err(StoreError::NotFound(_)) => continue,
279 +
                    Err(e) => return Err(e.into()),
280 +
                };
274 281
                let bundle = wire::decode_bundle(&bytes)
275 282
                    .map_err(|e| RepoError::Msg(format!("{}: {key}: {e}", self.what())))?;
276 283
                match mirror.import_bundle(&bundle, strict) {
…
5 unchanged lines
282 289
                    Err(e) => return Err(RepoError::Msg(format!("{}: {key}: {e}", self.what()))),
283 290
                }
284 291
            }
285 -
            if deferred.len() == pending.len() + deferred.len() && !deferred.is_empty() {
292 +
            if deferred.len() == before {
286 293
                // No progress: something those bundles need was never
287 294
                // pushed, or a push is still in progress.
288 295
                return fail(format!(
…
167 unchanged lines
456 463
                ));
457 464
            }
458 465
        }
459 -
        let (before_ref, before) = self.with_mirror(|m| m.head_op())?;
460 -
        let results =
461 -
            self.with_mirror(|m| m.receive_push(bundle.clone(), updates, policy, tags))?;
462 -
        if !results.iter().any(|r| r.ok) {
463 -
            return Ok(results);
466 +
        // The bundle is checked against the mirror and uploaded once; the
467 +
        // record is then moved from the version read. If it moved
468 +
        // meanwhile (another push landed), the mirror is put back, brought
469 +
        // level again, and the updates applied over what landed: two
470 +
        // pushes to different bookmarks both get through, and one to the
471 +
        // same bookmark is told it is stale, as a hub would say.
472 +
        let mut uploaded = false;
473 +
        for attempt in 0..PUSH_ATTEMPTS {
474 +
            let (before_ref, before) = self.with_mirror(|m| m.head_op())?;
475 +
            let results = self.with_mirror(|m| {
476 +
                m.receive_push(
477 +
                    bundle.clone(),
478 +
                    updates.clone(),
479 +
                    policy.clone(),
480 +
                    tags.clone(),
481 +
                )
482 +
            })?;
483 +
            if !results.iter().any(|r| r.ok) {
484 +
                return Ok(results);
485 +
            }
486 +
            if !bundle.is_empty() && !uploaded {
487 +
                self.upload(&bundle)?;
488 +
                uploaded = true;
489 +
            }
490 +
            let record = self.record(id)?;
491 +
            match self
492 +
                .store
493 +
                .write_ref(REFS_KEY, &record, synced.version.as_ref())
494 +
            {
495 +
                Ok(_) => return Ok(results),
496 +
                Err(StoreError::Conflict(_)) => {
497 +
                    self.with_mirror(|m| {
498 +
                        let (now, _) = m.head_op()?;
499 +
                        if now != before_ref {
500 +
                            m.record_op(
501 +
                                &[now],
502 +
                                "strata push undone: the remote moved",
503 +
                                before.view.clone(),
504 +
                            )?;
505 +
                        }
506 +
                        Ok(())
507 +
                    })?;
508 +
                    synced = self.sync()?;
509 +
                    if attempt + 1 == PUSH_ATTEMPTS {
510 +
                        return Ok(results
511 +
                            .into_iter()
512 +
                            .map(|r| RefResult {
513 +
                                name: r.name,
514 +
                                ok: false,
515 +
                                reason: "the remote kept moving while pushing (other pushes landed first); pull, then push again".into(),
516 +
                            })
517 +
                            .collect());
518 +
                    }
519 +
                }
520 +
                Err(e) => return Err(e.into()),
521 +
            }
464 522
        }
465 -
        if !bundle.is_empty() {
466 -
            self.upload(&bundle)?;
523 +
        unreachable!("the loop returns")
524 +
    }
525 +
}
526 +
527 +
/// How often a push is applied over what landed meanwhile before giving up.
528 +
const PUSH_ATTEMPTS: usize = 4;
529 +
530 +
/// The grace period before `gc` removes a bundle it merged: a reader that
531 +
/// listed it before the merge can still fetch it.
532 +
pub const REMOTE_GC_GRACE: std::time::Duration = std::time::Duration::from_secs(7 * 86_400);
533 +
534 +
/// Key of the record naming the bundles a gc merged, with when.
535 +
const TRASH_KEY: &str = "gc";
536 +
537 +
/// The superseded bundles and when (milliseconds) each was superseded.
538 +
type Trash = Vec<(String, u64)>;
539 +
540 +
/// What a gc against a store did.
541 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
542 +
pub struct RemoteGc {
543 +
    /// Bundles folded into one new bundle.
544 +
    pub merged: usize,
545 +
    /// The merged bundle's size, bytes.
546 +
    pub bundle_bytes: usize,
547 +
    /// Changes in the merged bundle.
548 +
    pub changes: usize,
549 +
    /// Superseded bundles removed, past the grace period.
550 +
    pub deleted: usize,
551 +
    /// Superseded bundles kept for now by the grace period.
552 +
    pub deferred: usize,
553 +
}
554 +
555 +
fn now_ms() -> u64 {
556 +
    std::time::SystemTime::now()
557 +
        .duration_since(std::time::UNIX_EPOCH)
558 +
        .map(|d| d.as_millis() as u64)
559 +
        .unwrap_or(0)
560 +
}
561 +
562 +
impl BucketRemote {
563 +
    /// The bundles a gc superseded, with when: one `KEY\tMILLIS` per line.
564 +
    fn trash(&self) -> Result<(Trash, Option<Version>)> {
565 +
        match self.store.read_ref(TRASH_KEY)? {
566 +
            Some((bytes, v)) => {
567 +
                let text = String::from_utf8_lossy(&bytes);
568 +
                let list = text
569 +
                    .lines()
570 +
                    .filter_map(|l| {
571 +
                        let (k, t) = l.split_once('\t')?;
572 +
                        Some((k.to_string(), t.parse().ok()?))
573 +
                    })
574 +
                    .collect();
575 +
                Ok((list, Some(v)))
576 +
            }
577 +
            None => Ok((Vec::new(), None)),
467 578
        }
468 -
        let record = self.record(id)?;
469 -
        match self
579 +
    }
580 +
581 +
    fn write_trash(&self, list: &[(String, u64)], expected: Option<&Version>) -> Result<Version> {
582 +
        let text: String = list.iter().map(|(k, t)| format!("{k}\t{t}\n")).collect();
583 +
        Ok(self.store.write_ref(TRASH_KEY, text.as_bytes(), expected)?)
584 +
    }
585 +
586 +
    /// Fold every bundle into one holding what the record reaches, and
587 +
    /// remove the bundles that made a gc redundant once `grace` has
588 +
    /// passed since they were superseded (stores.md §6).
589 +
    pub fn gc(&self, grace: std::time::Duration) -> Result<RemoteGc> {
590 +
        let synced = self.sync()?;
591 +
        let Some(id) = synced.id else {
592 +
            return fail(format!("{} holds no repository yet", self.what()));
593 +
        };
594 +
        let mut report = RemoteGc::default();
595 +
        let existing: Vec<String> = self
470 596
            .store
471 -
            .write_ref(REFS_KEY, &record, synced.version.as_ref())
472 -
        {
473 -
            Ok(_) => Ok(results),
474 -
            Err(StoreError::Conflict(_)) => {
475 -
                // Someone else moved the record meanwhile. The bundle is in
476 -
                // the store and harmless; the mirror goes back to what the
477 -
                // store says, and the push is reported as a hub would.
478 -
                self.with_mirror(|m| {
479 -
                    let (now, _) = m.head_op()?;
480 -
                    if now != before_ref {
481 -
                        m.record_op(
482 -
                            &[now],
483 -
                            "strata push undone: the remote moved",
484 -
                            before.view.clone(),
485 -
                        )?;
486 -
                    }
487 -
                    Ok(())
488 -
                })?;
489 -
                let _ = self.sync();
490 -
                Ok(results
491 -
                    .into_iter()
492 -
                    .map(|r| RefResult {
493 -
                        name: r.name,
494 -
                        ok: false,
495 -
                        reason: "the remote moved while pushing (another push landed first); pull, then push again".into(),
496 -
                    })
497 -
                    .collect())
597 +
            .list(BUNDLES_PREFIX)?
598 +
            .into_iter()
599 +
            .map(|e| e.key)
600 +
            .collect();
601 +
        let (mut trash, mut trash_version) = self.trash()?;
602 +
        let live: Vec<&String> = existing
603 +
            .iter()
604 +
            .filter(|k| !trash.iter().any(|(t, _)| t == *k))
605 +
            .collect();
606 +
        if live.len() > 1 {
607 +
            // Everything the record reaches, in dependency order, with
608 +
            // the state objects the bookmarks and tags name.
609 +
            let (bundle, changes) = self.with_mirror(|m| {
610 +
                let resp = m.remote_refs()?;
611 +
                let mut heads: Vec<ChangeHash> = Vec::new();
612 +
                for r in &resp.refs {
613 +
                    heads.extend(r.heads.iter().copied());
614 +
                }
615 +
                for t in &resp.tags {
616 +
                    heads.extend(t.heads.iter().copied());
617 +
                }
618 +
                heads.sort();
619 +
                heads.dedup();
620 +
                let changes = m.negotiate(&[], &heads)?;
621 +
                let mut bundle = m.bundle_for(&changes)?;
622 +
                for r in &resp.refs {
623 +
                    bundle.objects.push((
624 +
                        ObjectType::State,
625 +
                        StateObject::new(r.heads.clone()).encode(),
626 +
                    ));
627 +
                }
628 +
                for t in &resp.tags {
629 +
                    bundle.objects.push((
630 +
                        ObjectType::State,
631 +
                        StateObject::new(t.heads.clone()).encode(),
632 +
                    ));
633 +
                }
634 +
                Ok((bundle, changes.len()))
635 +
            })?;
636 +
            report.changes = changes;
637 +
            report.bundle_bytes = wire::encode_bundle(&bundle).len();
638 +
            let merged_key = self.upload(&bundle)?;
639 +
            let now = now_ms();
640 +
            for k in live {
641 +
                if *k != merged_key {
642 +
                    trash.push((k.clone(), now));
643 +
                    report.merged += 1;
644 +
                }
645 +
            }
646 +
            trash_version = Some(self.write_trash(&trash, trash_version.as_ref())?);
647 +
        }
648 +
        // What the grace period has released.
649 +
        let now = now_ms();
650 +
        let mut kept = Vec::new();
651 +
        for (k, when) in trash {
652 +
            if now.saturating_sub(when) >= grace.as_millis() as u64 {
653 +
                self.store.delete(&k)?;
654 +
                report.deleted += 1;
655 +
            } else {
656 +
                kept.push((k, when));
657 +
                report.deferred += 1;
498 658
            }
499 -
            Err(e) => Err(e.into()),
659 +
        }
660 +
        if report.deleted > 0 {
661 +
            self.write_trash(&kept, trash_version.as_ref())?;
500 662
        }
663 +
        let _ = id;
664 +
        Ok(report)
501 665
    }
502 666
}
503 667
668 +
impl Repo {
669 +
    /// `oxy gc --remote NAME`: a gc against a remote kept on a store.
670 +
    pub fn gc_remote(&self, name: &str, now: bool) -> Result<RemoteGc> {
671 +
        let Some(url) = self.config.remotes.get(name).cloned() else {
672 +
            return fail(format!("no remote {name:?}"));
673 +
        };
674 +
        let options = self.remote_options(name);
675 +
        let Some(store) = crate::protocol::open_store(&url, &options) else {
676 +
            return fail(format!(
677 +
                "gc --remote works on a remote kept on a store (s3://, gs://, az://, dir://); {name} is {url}"
678 +
            ));
679 +
        };
680 +
        let remote = BucketRemote::open(store?, &options)?;
681 +
        let grace = if now {
682 +
            std::time::Duration::ZERO
683 +
        } else {
684 +
            REMOTE_GC_GRACE
685 +
        };
686 +
        remote.gc(grace)
687 +
    }
688 +
}
689 +
504 690
/// Open a remote on `store` for the local repository described by `options`.
505 691
pub fn open_store_remote(
506 692
    store: Box<dyn Store>,
…
228 unchanged lines
735 921
    }
736 922
737 923
    #[test]
738 -
    fn a_record_moved_during_a_push_fails_the_push_and_keeps_the_mirror_honest() {
924 +
    fn a_record_moved_during_a_push_is_applied_over_what_landed() {
739 925
        hermetic();
740 926
        let dir = tempfile::tempdir().unwrap();
741 927
        let root = dir.path();
…
39 unchanged lines
781 967
        let r = remote
782 968
            .push(bundle, vec![update(state, state2)], None, vec![])
783 969
            .unwrap();
784 -
        assert!(
785 -
            !r[0].ok && r[0].reason.contains("moved while pushing"),
786 -
            "{r:?}"
787 -
        );
970 +
        assert!(r[0].ok, "applied over what landed meanwhile: {r:?}");
788 971
        let refs = remote.refs().unwrap();
789 972
        assert_eq!(
790 973
            refs.refs[0].heads,
791 -
            vec![h1],
792 -
            "the mirror says what the store says"
974 +
            vec![h2],
975 +
            "the store and the mirror both moved"
793 976
        );
794 977
    }
795 978
…
55 unchanged lines
851 1034
            "{e}"
852 1035
        );
853 1036
        assert!(!root.join("b/f.txt").exists());
1037 +
    }
1038 +
1039 +
    /// A store shared by two remotes in one test, with a hook run once at
1040 +
    /// the first conditional write: what another push landing between a
1041 +
    /// read of the record and its compare-and-swap looks like.
1042 +
    struct Shared {
1043 +
        inner: std::sync::Arc<MemoryStore>,
1044 +
        fail_uploads: std::sync::atomic::AtomicBool,
1045 +
        vanish: Mutex<Option<String>>,
1046 +
    }
1047 +
1048 +
    thread_local! {
1049 +
        static HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const { std::cell::RefCell::new(None) };
1050 +
    }
1051 +
1052 +
    impl Shared {
1053 +
        fn new(inner: std::sync::Arc<MemoryStore>) -> Box<Shared> {
1054 +
            Box::new(Shared {
1055 +
                inner,
1056 +
                fail_uploads: Default::default(),
1057 +
                vanish: Mutex::new(None),
1058 +
            })
1059 +
        }
1060 +
    }
1061 +
1062 +
    impl Store for Shared {
1063 +
        fn describe(&self) -> String {
1064 +
            "shared".into()
1065 +
        }
1066 +
        fn has(&self, key: &str) -> crate::strata::Result<bool> {
1067 +
            self.inner.has(key)
1068 +
        }
1069 +
        fn get(&self, key: &str) -> crate::strata::Result<Vec<u8>> {
1070 +
            let mut vanish = self.vanish.lock().unwrap();
1071 +
            if vanish.as_deref() == Some(key) {
1072 +
                *vanish = None;
1073 +
                return Err(StoreError::NotFound(key.to_string()));
1074 +
            }
1075 +
            self.inner.get(key)
1076 +
        }
1077 +
        fn get_range(&self, key: &str, range: Range<u64>) -> crate::strata::Result<Vec<u8>> {
1078 +
            self.inner.get_range(key, range)
1079 +
        }
1080 +
        fn put_new(&self, key: &str, bytes: &[u8]) -> crate::strata::Result<()> {
1081 +
            if key.starts_with("bundles/")
1082 +
                && self.fail_uploads.load(std::sync::atomic::Ordering::SeqCst)
1083 +
            {
1084 +
                return Err(StoreError::Backend("shared: the disk is full".into()));
1085 +
            }
1086 +
            self.inner.put_new(key, bytes)
1087 +
        }
1088 +
        fn list(&self, prefix: &str) -> crate::strata::Result<Vec<Entry>> {
1089 +
            self.inner.list(prefix)
1090 +
        }
1091 +
        fn delete(&self, key: &str) -> crate::strata::Result<()> {
1092 +
            self.inner.delete(key)
1093 +
        }
1094 +
        fn read_ref(&self, key: &str) -> crate::strata::Result<Option<(Vec<u8>, Version)>> {
1095 +
            self.inner.read_ref(key)
1096 +
        }
1097 +
        fn write_ref(
1098 +
            &self,
1099 +
            key: &str,
1100 +
            bytes: &[u8],
1101 +
            expected: Option<&Version>,
1102 +
        ) -> crate::strata::Result<Version> {
1103 +
            if key == "refs" && expected.is_some() {
1104 +
                if let Some(hook) = HOOK.with(|h| h.borrow_mut().take()) {
1105 +
                    hook();
1106 +
                }
1107 +
            }
1108 +
            self.inner.write_ref(key, bytes, expected)
1109 +
        }
1110 +
    }
1111 +
1112 +
    fn repo_with_commit(root: &Path, name: &str, file: &str, text: &[u8], msg: &str) -> Repo {
1113 +
        let repo = Repo::init(&root.join(name)).unwrap();
1114 +
        write(&root.join(name), file, text);
1115 +
        repo.commit(msg, &[]).unwrap();
1116 +
        repo
1117 +
    }
1118 +
1119 +
    fn remote_for(repo: &Repo, store: Box<dyn Store>, root: &Path, name: &str) -> BucketRemote {
1120 +
        let options = RemoteOptions {
1121 +
            repo_id: Some(repo.id().unwrap()),
1122 +
            cache_dir: Some(root.join(format!("cache-{name}"))),
1123 +
            ..Default::default()
1124 +
        };
1125 +
        BucketRemote::open(store, &options).unwrap()
1126 +
    }
1127 +
1128 +
    fn push_bookmark(
1129 +
        repo: &Repo,
1130 +
        remote: &BucketRemote,
1131 +
        bookmark: &str,
1132 +
        old: Option<ObjectRef>,
1133 +
    ) -> Vec<RefResult> {
1134 +
        let heads = repo.heads_of(&repo.view().unwrap(), bookmark).unwrap();
1135 +
        let state = repo
1136 +
            .store
1137 +
            .put_state(&StateObject::new(heads.clone()))
1138 +
            .unwrap();
1139 +
        let empty = repo.store.put_state(&StateObject::new(vec![])).unwrap();
1140 +
        // Only what the remote lacks, as push_bookmark sends.
1141 +
        let have: Vec<ChangeHash> = remote
1142 +
            .refs()
1143 +
            .unwrap()
1144 +
            .refs
1145 +
            .iter()
1146 +
            .flat_map(|r| r.heads.clone())
1147 +
            .collect();
1148 +
        let changes = repo.negotiate(&have, &heads).unwrap();
1149 +
        let mut bundle = repo.bundle_for(&changes).unwrap();
1150 +
        bundle
1151 +
            .objects
1152 +
            .push((ObjectType::State, StateObject::new(heads).encode()));
1153 +
        let update = RefUpdate {
1154 +
            name: bookmark.into(),
1155 +
            old_state: old.unwrap_or(empty),
1156 +
            new_state: state,
1157 +
            force: false,
1158 +
            attestation: None,
1159 +
        };
1160 +
        remote.push(bundle, vec![update], None, vec![]).unwrap()
1161 +
    }
1162 +
1163 +
    #[test]
1164 +
    fn two_pushes_to_different_bookmarks_both_land() {
1165 +
        hermetic();
1166 +
        let dir = tempfile::tempdir().unwrap();
1167 +
        let root = dir.path();
1168 +
        let inner = std::sync::Arc::new(MemoryStore::new());
1169 +
        // A pushes main; B (a clone of the same repository) pushes a
1170 +
        // second bookmark between A's read and A's compare-and-swap.
1171 +
        let a = repo_with_commit(root, "a", "f.txt", b"a\n", "first");
1172 +
        let ra = remote_for(&a, Shared::new(inner.clone()), root, "a");
1173 +
        let r = push_bookmark(&a, &ra, "main", None);
1174 +
        assert!(r[0].ok, "{r:?}");
1175 +
        let b = Repo::init_with_id(&root.join("b"), a.id().unwrap()).unwrap();
1176 +
        let rb = remote_for(&b, Shared::new(inner.clone()), root, "b");
1177 +
        rb.refs().unwrap();
1178 +
        write(&root.join("b"), "g.txt", b"g\n");
1179 +
        b.commit("on b", &[]).unwrap();
1180 +
        let (_, op) = b.head_op().unwrap();
1181 +
        let mut view = op.view.clone();
1182 +
        let feature_heads = b.heads_of(&view, "main").unwrap();
1183 +
        view.bookmarks.insert(
1184 +
            "feature".into(),
1185 +
            b.store.put_state(&StateObject::new(feature_heads)).unwrap(),
1186 +
        );
1187 +
        b.record_op(&[b.head_op().unwrap().0], "bookmark create feature", view)
1188 +
            .unwrap();
1189 +
        // A's second push to main; B's push of feature lands in between.
1190 +
        write(&root.join("a"), "f.txt", b"aa\n");
1191 +
        a.commit("second", &[]).unwrap();
1192 +
        let a_old = ra
1193 +
            .refs()
1194 +
            .unwrap()
1195 +
            .refs
1196 +
            .iter()
1197 +
            .find(|r| r.name == "main")
1198 +
            .unwrap()
1199 +
            .state;
1200 +
        let b_remote = std::sync::Arc::new(std::sync::Mutex::new(Some(rb)));
1201 +
        let b_handle = b_remote.clone();
1202 +
        HOOK.with(|h| {
1203 +
            *h.borrow_mut() = Some(Box::new(move || {
1204 +
                let rb = b_handle.lock().unwrap().take().unwrap();
1205 +
                let r = push_bookmark(&b, &rb, "feature", None);
1206 +
                assert!(r[0].ok, "{r:?}");
1207 +
            }))
1208 +
        });
1209 +
        let r = push_bookmark(&a, &ra, "main", Some(a_old));
1210 +
        assert!(r[0].ok, "a's push is applied over b's: {r:?}");
1211 +
        let refs = ra.refs().unwrap();
1212 +
        let names: Vec<&str> = refs.refs.iter().map(|r| r.name.as_str()).collect();
1213 +
        assert_eq!(names, ["feature", "main"], "both landed");
1214 +
        assert_eq!(inner.list("bundles/").unwrap().len(), 3);
1215 +
        let _ = b_remote;
1216 +
    }
1217 +
1218 +
    #[test]
1219 +
    fn an_interrupted_push_leaves_the_store_usable() {
1220 +
        hermetic();
1221 +
        let dir = tempfile::tempdir().unwrap();
1222 +
        let root = dir.path();
1223 +
        let inner = std::sync::Arc::new(MemoryStore::new());
1224 +
        let a = repo_with_commit(root, "a", "f.txt", b"a\n", "first");
1225 +
        let shared = Shared::new(inner.clone());
1226 +
        let fail = &shared.fail_uploads as *const std::sync::atomic::AtomicBool;
1227 +
        let ra = remote_for(&a, shared, root, "a");
1228 +
        assert!(push_bookmark(&a, &ra, "main", None)[0].ok);
1229 +
        let old = ra.refs().unwrap().refs[0].state;
1230 +
        // The upload fails: nothing in the store changes, the mirror is
1231 +
        // honest, and the next push gets through.
1232 +
        write(&root.join("a"), "f.txt", b"aa\n");
1233 +
        let h2 = a.commit("second", &[]).unwrap();
1234 +
        unsafe { (*fail).store(true, std::sync::atomic::Ordering::SeqCst) };
1235 +
        let e = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1236 +
            push_bookmark(&a, &ra, "main", Some(old))
1237 +
        }));
1238 +
        assert!(e.is_err(), "the push reports the failed upload");
1239 +
        assert_eq!(inner.list("bundles/").unwrap().len(), 1);
1240 +
        let refs = ra.refs().unwrap();
1241 +
        assert_eq!(refs.refs[0].state, old, "the record did not move");
1242 +
        unsafe { (*fail).store(false, std::sync::atomic::Ordering::SeqCst) };
1243 +
        let r = push_bookmark(&a, &ra, "main", Some(old));
1244 +
        assert!(r[0].ok, "{r:?}");
1245 +
        assert_eq!(ra.refs().unwrap().refs[0].heads, vec![h2]);
1246 +
    }
1247 +
1248 +
    #[test]
1249 +
    fn bundles_out_of_order_or_gone_are_handled() {
1250 +
        hermetic();
1251 +
        let dir = tempfile::tempdir().unwrap();
1252 +
        let root = dir.path();
1253 +
        let inner = std::sync::Arc::new(MemoryStore::new());
1254 +
        let a = repo_with_commit(root, "a", "f.txt", b"a\n", "first");
1255 +
        let ra = remote_for(&a, Shared::new(inner.clone()), root, "a");
1256 +
        assert!(push_bookmark(&a, &ra, "main", None)[0].ok);
1257 +
        let old = ra.refs().unwrap().refs[0].state;
1258 +
        write(&root.join("a"), "f.txt", b"aa\n");
1259 +
        let h2 = a.commit("second", &[]).unwrap();
1260 +
        assert!(push_bookmark(&a, &ra, "main", Some(old))[0].ok);
1261 +
        // The second bundle (which needs the first) renamed to sort first.
1262 +
        let keys: Vec<String> = inner
1263 +
            .list("bundles/")
1264 +
            .unwrap()
1265 +
            .into_iter()
1266 +
            .map(|e| e.key)
1267 +
            .collect();
1268 +
        assert_eq!(keys.len(), 2);
1269 +
        let bytes = inner.get(&keys[1]).unwrap();
1270 +
        inner.delete(&keys[1]).unwrap();
1271 +
        inner
1272 +
            .put_new("bundles/00000000-first.bundle", &bytes)
1273 +
            .unwrap();
1274 +
        let shared = Shared::new(inner.clone());
1275 +
        let c = Repo::init_with_id(&root.join("c"), a.id().unwrap()).unwrap();
1276 +
        let rc = remote_for(&c, shared, root, "c");
1277 +
        assert_eq!(
1278 +
            rc.refs().unwrap().refs[0].heads,
1279 +
            vec![h2],
1280 +
            "the dependent bundle waited for its dependency"
1281 +
        );
1282 +
        // A bundle gone between the listing and the read is skipped.
1283 +
        let shared = Shared::new(inner.clone());
1284 +
        *shared.vanish.lock().unwrap() = Some(keys[0].clone());
1285 +
        let d = Repo::init_with_id(&root.join("d"), a.id().unwrap()).unwrap();
1286 +
        let rd = remote_for(&d, shared, root, "d");
1287 +
        // Without the first bundle the second cannot be imported: the sync
1288 +
        // says so rather than failing silently.
1289 +
        let e = rd.refs().expect_err("missing changes").to_string();
1290 +
        assert!(e.contains("cannot be imported"), "{e}");
1291 +
    }
1292 +
1293 +
    #[test]
1294 +
    fn gc_merges_bundles_and_prunes_after_the_grace_period() {
1295 +
        hermetic();
1296 +
        let dir = tempfile::tempdir().unwrap();
1297 +
        let root = dir.path();
1298 +
        let url = format!("dir://{}", root.join("bucket").display());
1299 +
        let store = LocalStore::open(root.join("bucket")).unwrap();
1300 +
        let mut a = Repo::init(&root.join("a")).unwrap();
1301 +
        add_remote(&mut a, "bucket", &url);
1302 +
        for i in 1..=3 {
1303 +
            write(&root.join("a"), "f.txt", format!("{i}\n").as_bytes());
1304 +
            a.commit(&format!("change {i}"), &[]).unwrap();
1305 +
            assert!(a.push("bucket", false).unwrap()[0].ok);
1306 +
        }
1307 +
        a.tag_create("v3", &a.bookmark_heads().unwrap(), "three")
1308 +
            .unwrap();
1309 +
        assert!(a.push("bucket", false).unwrap().iter().all(|r| r.ok));
1310 +
        assert_eq!(
1311 +
            keys(&store, "bundles/").len(),
1312 +
            4,
1313 +
            "three pushes and the tag's"
1314 +
        );
1315 +
        // A gc folds the four into one; the old ones wait out the grace.
1316 +
        let r = a.gc_remote("bucket", false).unwrap();
1317 +
        assert_eq!(
1318 +
            (r.merged, r.changes, r.deleted, r.deferred),
1319 +
            (4, 3, 0, 4),
1320 +
            "{r:?}"
1321 +
        );
1322 +
        assert!(r.bundle_bytes > 0);
1323 +
        assert_eq!(keys(&store, "bundles/").len(), 5);
1324 +
        let b = Repo::clone(&url, &root.join("b")).unwrap();
1325 +
        assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"3\n");
1326 +
        assert_eq!(b.tags().unwrap().len(), 1);
1327 +
        // Past the grace period they go, and a clone needs only the one.
1328 +
        let r = a.gc_remote("bucket", true).unwrap();
1329 +
        assert_eq!((r.merged, r.deleted, r.deferred), (0, 4, 0), "{r:?}");
1330 +
        assert_eq!(keys(&store, "bundles/").len(), 1);
1331 +
        let c = Repo::clone(&url, &root.join("c")).unwrap();
1332 +
        assert_eq!(c.bookmark_heads().unwrap(), a.bookmark_heads().unwrap());
1333 +
        // The earlier clone, which saw the old bundles, carries on.
1334 +
        write(&root.join("b"), "f.txt", b"4\n");
1335 +
        b.commit("four", &[]).unwrap();
1336 +
        assert!(b.push("origin", false).unwrap()[0].ok);
1337 +
        assert!(a.pull("bucket").unwrap().changed);
1338 +
        assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"4\n");
1339 +
        // Nothing more to merge: a second gc is a no-op.
1340 +
        let r = a.gc_remote("bucket", true).unwrap();
1341 +
        assert_eq!((r.merged, r.deleted), (2, 2));
1342 +
        assert!(a.gc_remote("nope", true).is_err());
854 1343
    }
855 1344
856 1345
    #[test]
…
13 unchanged lines
modified docs/stores.md +10 −3
…
132 unchanged lines
133 133
  mirror costs a second copy of the objects on disk; sharing them with
134 134
  the repository's own store is a later optimisation.
135 135
- **The record moved meanwhile:** the mirror is put back to what the
136 -
  store says and the push is reported as a hub reports a stale push:
137 -
  pull, then push again. Merging two pushes to different bookmarks
138 -
  without a round trip is ST5's work.
136 +
  store says, brought level, and the push's updates are applied over
137 +
  what landed (up to four times). Two pushes to different bookmarks
138 +
  both get through; one to the same bookmark is told it is stale, as a
139 +
  hub says: pull, then push again.
140 +
- **`oxy gc --remote NAME`** folds every bundle into one holding what
141 +
  the record reaches, and records the superseded ones in a `gc`
142 +
  record with the time. They are removed only once seven days have
143 +
  passed (`--now` ignores that), so a reader that listed them before
144 +
  the fold can still fetch them; a reader that finds one gone skips it,
145 +
  since the merged bundle holds the same changes.
139 146
- **A directory is a store too:** `oxy remote add usb dir:///media/usb/repo`
140 147
  (`dir://`, since a plain path or `file://` is another repository).
141 148
  Tests run the whole flow through it with no network.
…
90 unchanged lines
modified src/cli/mod.rs +7 −1
…
306 unchanged lines
307 307
        /// Ignore the grace period (core.gc-grace) and prune immediately
308 308
        #[arg(long)]
309 309
        now: bool,
310 +
        /// Instead: fold the bundles of a remote kept on a store into one, and remove the ones a gc superseded 7 days ago (or now, with --now)
311 +
        #[arg(long, value_name = "NAME")]
312 +
        remote: Option<String>,
310 313
    },
311 314
    /// List operations
312 315
    #[command(name = "op")]
…
794 unchanged lines
1107 1110
        } => workspace::checkout(&changes, discard, carry),
1108 1111
        Cmd::Resolve { json } => workspace::resolve(json),
1109 1112
        Cmd::Undo => workspace::undo(),
1110 -
        Cmd::Gc { now } => workspace::gc(now),
1113 +
        Cmd::Gc { now, remote } => match remote {
1114 +
            Some(name) => workspace::gc_remote(&name, now),
1115 +
            None => workspace::gc(now),
1116 +
        },
1111 1117
        Cmd::Fsck { json } => workspace::fsck(json),
1112 1118
        Cmd::Doctor { offline, json } => workspace::doctor(offline, json),
1113 1119
        Cmd::Checkpoint => workspace::checkpoint(),
…
238 unchanged lines
modified src/cli/workspace.rs +40 −0
…
1498 unchanged lines
1499 1499
    Ok(0)
1500 1500
}
1501 1501
1502 +
/// `oxy gc --remote NAME`: fold a store remote's bundles into one.
1503 +
pub fn gc_remote(name: &str, now: bool) -> Result<i32> {
1504 +
    let repo = open()?;
1505 +
    let sp = ui::spinner(&format!("Folding {name}'s bundles…"));
1506 +
    let r = repo.gc_remote(name, now)?;
1507 +
    sp.clear();
1508 +
    if r.merged > 0 {
1509 +
        println!(
1510 +
            "{} {} into one of {} bytes ({})",
1511 +
            ui::done("Folded"),
1512 +
            plural(r.merged, "bundle"),
1513 +
            r.bundle_bytes,
1514 +
            plural(r.changes, "change")
1515 +
        );
1516 +
    } else {
1517 +
        println!(
1518 +
            "{} {}",
1519 +
            ui::note(),
1520 +
            ui::dim("nothing to fold: one bundle holds everything")
1521 +
        );
1522 +
    }
1523 +
    println!(
1524 +
        "  {} {}",
1525 +
        ui::note(),
1526 +
        ui::dim(format!(
1527 +
            "removed {}{}",
1528 +
            plural(r.deleted, "superseded bundle"),
1529 +
            if r.deferred > 0 {
1530 +
                format!(
1531 +
                    "; {} kept for readers that may still fetch them (use --now)",
1532 +
                    plural(r.deferred, "bundle")
1533 +
                )
1534 +
            } else {
1535 +
                String::new()
1536 +
            }
1537 +
        ))
1538 +
    );
1539 +
    Ok(0)
1540 +
}
1541 +
1502 1542
/// `oxy doctor`: exit 1 when a check fails, 0 otherwise (warnings allowed).
1503 1543
pub fn doctor(offline: bool, as_json: bool) -> Result<i32> {
1504 1544
    use oxy_core::repo::Health;
…
900 unchanged lines
modified tests/cli_strata.rs +21 −0
…
104 unchanged lines
105 105
        "hello\nagain\n"
106 106
    );
107 107
108 +
    // Two pushes made two bundles; a gc folds them into one, and a clone
109 +
    // after it is whole.
110 +
    let out = ok(&a, &home, &["gc", "--remote", "usb", "--now"]);
111 +
    assert!(
112 +
        out.contains("Folded 2 bundles") && out.contains("removed 2 superseded bundles"),
113 +
        "{out}"
114 +
    );
115 +
    assert_eq!(
116 +
        std::fs::read_dir(bucket.join("bundles")).unwrap().count(),
117 +
        1
118 +
    );
119 +
    let d = tmp.path().join("d");
120 +
    ok(tmp.path(), &home, &["clone", &url, d.to_str().unwrap()]);
121 +
    assert_eq!(
122 +
        std::fs::read_to_string(d.join("f.txt")).unwrap(),
123 +
        "hello\nagain\n"
124 +
    );
125 +
    let (code, _, err) = oxy(&a, &home, &["gc", "--remote", "nope"]);
126 +
    assert_ne!(code, 0);
127 +
    assert!(err.contains("no remote"), "{err}");
128 +
108 129
    // Another repository is told the store is taken.
109 130
    let c = tmp.path().join("c");
110 131
    ok(tmp.path(), &home, &["init", c.to_str().unwrap()]);
…
12 unchanged lines