OXYGENHUB

Push a long history in parts cb8b8082fa73

oxy push sent the whole bundle in one request, built in memory on both
sides. It now weighs the changes to send (each change's stored bytes and
its text blobs and replaced blobs) and sends them in dependency order in
parts of about push.batch-bytes (default 256 MiB): every part but the
last carries objects alone, and the last carries the reference updates,
tags and policy as before. Seals are stored first, so each part's bundle
carries those its changes need. A push with no update and no tag reports
its outcome as an `objects` result (ok, or why the changes were
refused); the HTTP server drops it when it refused the updates itself.
The CLI spinner names the part going. Spec §16.4.

closes #95
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-10 03:48 UTC
Signature
✔ signed by Andy McCall
Hash
cb8b8082fa735a624604ee58bcc4c17082a9370a63759452f46477686de98ce6
Depends on
05e71ed6cdf1 135212de5370 170bb436e1d5 252e8cb23fbc 2b5ee371060e 5552e69c7325 750251b21a96 75167672ebcd 7aa2dc9369e3 7b9809e67c3d 83e0677b07fe 8c30d814f2cc 8fd78eb52ad2 982864068a59 b3c5a59a875a f8c695764c95 fce44ae9ad29 ff0af1ee299d
Depended on by
569802ebae0e 80b069facc69 c8d9059e4e70 d3b270909c78 b63fc7a77fc6 67a4fe1aa2d5 a2387d547373 34fad4a92451 d6e0d5b8b339 aedfe465b611
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.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
24 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A long push goes in parts (#95).** `oxy push` sent a whole history in one request, held in memory on both sides; the full VS Code import would have passed a hub's 4 GiB limit. It now sends parts of about `push.batch-bytes` (default 256 MiB) in dependency order, objects alone, and the last part moves the bookmark; the spinner says which part is going. A server stores a part and reports it as `objects`.
6 +
5 7
- **A state index read only as far as it is used (#94).** Each file's history in the workspace's state index is stored after the rest with a hash of its own, and is read and checked only when a command builds that file, so a diff or a one-file cat no longer reads and hashes the histories of every file (67 MB of an 84 MB index on VS Code at 20 000 commits). Changes added on top of an index no longer decode the histories they touch. A history found damaged is an error that says so, and the index is removed and rebuilt by the next command. Under load on that repository, `diff` went from 85 ms to 63 ms and `cat`/`blame` from 102 ms to 80 ms.
6 8
7 9
- **Removing a file removes every copy of it (#88).** When two lines of history each put a file back in the same place, it has two placements there and shows as one file. Removing or moving it retired only one, so the other stayed behind, and once its directory went it was an orphan: a conflict in the state. It is the one difference the Git import of VS Code had in its first 31 000 commits. Both now go together, in a commit and in a Git import.
…
156 unchanged lines
modified crates/oxy-core/src/repo/config.rs +9 −0
…
36 unchanged lines
37 37
    pub gc_grace_days: u64,
38 38
    /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11).
39 39
    pub checkpoint_interval: u64,
40 +
    /// `push.batch-bytes`: about how many bytes of changes and their texts
41 +
    /// one request of a long push carries (spec §16.3).
42 +
    pub push_batch_bytes: u64,
40 43
    /// `filter.max-blob`: blobs larger than this many bytes are fetched lazily (spec §17).
41 44
    pub max_blob: Option<u64>,
42 45
    /// `filter.paths`: only these subtrees are materialized (spec §17); empty means everything.
…
38 unchanged lines
81 84
            op_retention_days: 90,
82 85
            gc_grace_days: 14,
83 86
            checkpoint_interval: 256,
87 +
            push_batch_bytes: 256 << 20,
84 88
            max_blob: None,
85 89
            sparse_paths: Vec::new(),
86 90
            semantic_deps: false,
…
102 unchanged lines
189 193
                    .parse()
190 194
                    .map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
191 195
            }
196 +
            "push.batch-bytes" => {
197 +
                self.push_batch_bytes = value.parse().ok().filter(|n| *n > 0).ok_or_else(|| {
198 +
                    RepoError::Msg(format!("expected a number of bytes, got {value:?}"))
199 +
                })?;
200 +
            }
192 201
            k if k.starts_with("remote.") => {
193 202
                let rest = &k["remote.".len()..];
194 203
                let Some((name, field)) = rest.rsplit_once('.') else {
…
125 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +1 −1
…
153 unchanged lines
154 154
    Selection, SnapshotResult,
155 155
};
156 156
pub use subrepos::{SubrepoInfo, SubrepoSync};
157 -
pub use sync::{FetchReport, PullReport, PushReport, RefUpdated};
157 +
pub use sync::{FetchReport, PullReport, PushReport, RefUpdated, OBJECTS_RESULT};
158 158
pub use tags::TagInfo;
159 159
pub use workspace::{FileLock, SnapshotLock, WorkingCopyLock, WorkspaceInfo};
160 160
…
2228 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +116 −7
…
43 unchanged lines
44 44
    pub others: Vec<String>,
45 45
}
46 46
47 +
/// The result a push of objects alone reports (a batch of a long history):
48 +
/// not a reference.
49 +
pub const OBJECTS_RESULT: &str = "objects";
50 +
47 51
impl Repo {
48 52
    // ------------------------------------------------------------ serving (spec §16)
49 53
…
586 unchanged lines
636 640
            }
637 641
        }
638 642
        let policy = self.current_policy()?.map(|(_, sp)| sp.policy);
643 +
        // A push of objects alone (one batch of a long history, oxygen#95)
644 +
        // names no reference: its outcome is reported as `objects`.
645 +
        let objects_only = updates.is_empty() && tags.is_empty();
639 646
        match self.import_bundle(&bundle, policy.is_some()) {
640 -
            Ok(imported) => report.imported = imported,
647 +
            Ok(imported) => {
648 +
                if objects_only {
649 +
                    results.push(RefResult {
650 +
                        name: OBJECTS_RESULT.into(),
651 +
                        ok: true,
652 +
                        reason: format!("{} changes stored", imported.len()),
653 +
                    });
654 +
                }
655 +
                report.imported = imported;
656 +
            }
641 657
            Err(e) => {
658 +
                if objects_only {
659 +
                    results.push(RefResult {
660 +
                        name: OBJECTS_RESULT.into(),
661 +
                        ok: false,
662 +
                        reason: e.to_string(),
663 +
                    });
664 +
                }
642 665
                for u in updates {
643 666
                    results.push(RefResult {
644 667
                        name: u.name,
…
971 unchanged lines
1616 1639
        Ok(results)
1617 1640
    }
1618 1641
1642 +
    /// `changes`, in dependency order, cut into batches of about
1643 +
    /// `push.batch-bytes` by what each change and its texts and blobs weigh.
1644 +
    /// There is always one, perhaps empty.
1645 +
    fn push_batches(&self, changes: &[ChangeHash]) -> Result<Vec<Vec<ChangeHash>>> {
1646 +
        let blob_len = |r: &ObjectRef| -> u64 {
1647 +
            self.store
1648 +
                .get_typed(r, ObjectType::Blob)
1649 +
                .ok()
1650 +
                .and_then(|b| Blob::decode(&b).ok())
1651 +
                .map_or(0, |b| b.length)
1652 +
        };
1653 +
        let (mut out, mut batch, mut bytes) = (Vec::new(), Vec::new(), 0u64);
1654 +
        for h in changes {
1655 +
            let raw = self.store.get_typed(h, ObjectType::Change)?;
1656 +
            let (c, texts) = Change::decode_skeleton(&raw, *h)?;
1657 +
            let mut weight = raw.len() as u64;
1658 +
            for t in &texts {
1659 +
                if let crate::objects::TextRef::Blob(r, _, _) = t {
1660 +
                    weight += blob_len(r);
1661 +
                }
1662 +
            }
1663 +
            for e in &c.edits {
1664 +
                if let Edit::Replace { new: Some(b), .. } = e {
1665 +
                    weight += blob_len(b);
1666 +
                }
1667 +
            }
1668 +
            if !batch.is_empty() && bytes + weight > self.config.push_batch_bytes {
1669 +
                out.push(std::mem::take(&mut batch));
1670 +
                bytes = 0;
1671 +
            }
1672 +
            batch.push(*h);
1673 +
            bytes += weight;
1674 +
        }
1675 +
        out.push(batch);
1676 +
        Ok(out)
1677 +
    }
1678 +
1619 1679
    pub fn push(&self, remote_name: &str, force: bool) -> Result<Vec<RefResult>> {
1620 1680
        let bookmark = self.config.bookmark.clone();
1621 1681
        self.push_bookmark(remote_name, &bookmark, force)
…
1 unchanged lines
1623 1683
1624 1684
    /// `push` for a named bookmark (`oxy push -b NAME`).
1625 1685
    pub fn push_bookmark(
1686 +
        &self,
1687 +
        remote_name: &str,
1688 +
        bookmark: &str,
1689 +
        force: bool,
1690 +
    ) -> Result<Vec<RefResult>> {
1691 +
        self.push_bookmark_with(remote_name, bookmark, force, &mut |_| {})
1692 +
    }
1693 +
1694 +
    /// `push_bookmark`, saying how a long push is going: one line per
1695 +
    /// batch sent (oxygen#95).
1696 +
    pub fn push_bookmark_with(
1626 1697
        &self,
1627 1698
        remote_name: &str,
1628 1699
        bookmark: &str,
1629 1700
        force: bool,
1701 +
        progress: crate::store::Progress,
1630 1702
    ) -> Result<Vec<RefResult>> {
1631 1703
        let (op_ref, op) = self.head_op()?;
1632 1704
        if !op.view.bookmarks.contains_key(bookmark) {
…
73 unchanged lines
1706 1778
            }
1707 1779
        }
1708 1780
        let to_send = self.negotiate(&remote_heads, &local_heads)?;
1709 -
        let mut bundle = self.bundle_for(&to_send)?;
1710 1781
        let key = self.secret_key()?.map(|(k, _)| k);
1711 1782
        if let Some(key) = key {
1712 1783
            // Seal this author's changes that carry no valid authenticator,
1713 -
            // and adopt anonymous ones (zero author, spec §14.4).
1784 +
            // and adopt anonymous ones (zero author, spec §14.4). The seals
1785 +
            // are stored, so each batch's bundle carries those it needs.
1714 1786
            let mut unsigned = Vec::new();
1715 1787
            for h in &to_send {
1716 -
                let c = self.store.get_change(h)?;
1788 +
                let c = self.store.get_change_meta(h)?;
1717 1789
                if (c.author == key || c.author.is_zero())
1718 1790
                    && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. })
1719 1791
                {
…
1 unchanged lines
1721 1793
                }
1722 1794
            }
1723 1795
            for chunk in unsigned.chunks(crate::attest::MAX_SEAL_HASHES) {
1724 -
                let sa = self.sign_attest(&Attest::seal(self.id()?, chunk.to_vec()))?;
1725 -
                bundle.attests.push(sa);
1796 +
                self.sign_attest(&Attest::seal(self.id()?, chunk.to_vec()))?;
1726 1797
            }
1727 -
            bundle = self.bundle_for(&to_send)?;
1798 +
        }
1799 +
        // A long history goes in batches, in dependency order, so neither
1800 +
        // side holds it whole and no request passes the remote's limit:
1801 +
        // all but the last carry objects alone; the last moves the bookmark
1802 +
        // (oxygen#95).
1803 +
        let batches = self.push_batches(&to_send)?;
1804 +
        let (last, earlier) = batches.split_last().expect("at least one batch");
1805 +
        for (i, batch) in earlier.iter().enumerate() {
1806 +
            progress(&format!(
1807 +
                "part {} of {}: {} changes",
1808 +
                i + 1,
1809 +
                batches.len(),
1810 +
                batch.len()
1811 +
            ));
1812 +
            let results = remote.push(self.bundle_for(batch)?, vec![], None, vec![])?;
1813 +
            match results.iter().find(|r| r.name == OBJECTS_RESULT) {
1814 +
                Some(r) if r.ok => {}
1815 +
                Some(r) => {
1816 +
                    return fail(format!(
1817 +
                        "{remote_name} refused part {} of the push: {}",
1818 +
                        i + 1,
1819 +
                        r.reason
1820 +
                    ))
1821 +
                }
1822 +
                None => {
1823 +
                    return fail(format!(
1824 +
                        "{remote_name} does not take a push in parts, and this one is too long for one: update the server's oxy"
1825 +
                    ))
1826 +
                }
1827 +
            }
1828 +
        }
1829 +
        if batches.len() > 1 {
1830 +
            progress(&format!(
1831 +
                "part {} of {}: {} changes",
1832 +
                batches.len(),
1833 +
                batches.len(),
1834 +
                last.len()
1835 +
            ));
1728 1836
        }
1837 +
        let mut bundle = self.bundle_for(last)?;
1729 1838
        bundle.objects.push((
1730 1839
            ObjectType::State,
1731 1840
            StateObject::new(local_heads.clone()).encode(),
…
256 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +53 −0
…
8114 unchanged lines
8115 8115
        assert_eq!(&repo.file_at(&format!("f{i}.txt"), None).unwrap(), w);
8116 8116
    }
8117 8117
}
8118 +
8119 +
/// oxygen#95: a push longer than `push.batch-bytes` goes in parts, each
8120 +
/// objects alone and in dependency order, and the last moves the bookmark.
8121 +
/// The remote ends with every change and the same state, and says how each
8122 +
/// part went.
8123 +
#[test]
8124 +
fn a_long_push_goes_in_parts() {
8125 +
    hermetic();
8126 +
    let dir = tempfile::tempdir().unwrap();
8127 +
    let root = dir.path();
8128 +
    let origin = root.join("origin");
8129 +
    Repo::init_bare_unbound(&origin).unwrap();
8130 +
    let main = root.join("main");
8131 +
    let mut repo = Repo::init(&main).unwrap();
8132 +
    repo.config
8133 +
        .set("remote.origin.url", &origin.to_string_lossy())
8134 +
        .unwrap();
8135 +
    // One byte: every change is a part of its own.
8136 +
    repo.config.set("push.batch-bytes", "1").unwrap();
8137 +
    repo.save_config().unwrap();
8138 +
    for i in 0..6 {
8139 +
        write(
8140 +
            &main,
8141 +
            &format!("f{i}.txt"),
8142 +
            format!("file {i}\n").as_bytes(),
8143 +
        );
8144 +
        repo.commit(&format!("c{i}"), &[]).unwrap();
8145 +
    }
8146 +
    let mut parts = Vec::new();
8147 +
    let results = repo
8148 +
        .push_bookmark_with("origin", "main", false, &mut |m| parts.push(m.to_string()))
8149 +
        .unwrap();
8150 +
    assert!(
8151 +
        results.iter().any(|r| r.ok && r.name == "main"),
8152 +
        "{results:?}"
8153 +
    );
8154 +
    assert_eq!(parts.len(), 6, "{parts:?}");
8155 +
    assert_eq!(parts[0], "part 1 of 6: 1 changes");
8156 +
8157 +
    let remote = Repo::open(&origin).unwrap();
8158 +
    let heads = repo.bookmark_heads().unwrap();
8159 +
    assert_eq!(remote.bookmark_heads().unwrap(), heads);
8160 +
    assert_eq!(
8161 +
        remote.log_state(&heads).unwrap().len(),
8162 +
        repo.log_state(&heads).unwrap().len()
8163 +
    );
8164 +
    // A push of objects alone reports how it went, as `objects`.
8165 +
    let got = remote
8166 +
        .receive_push(Bundle::default(), vec![], None, vec![])
8167 +
        .unwrap();
8168 +
    assert_eq!(got.len(), 1);
8169 +
    assert!(got[0].ok && got[0].name == OBJECTS_RESULT, "{got:?}");
8170 +
}
modified crates/oxy-server/src/lib.rs +7 −0
…
290 unchanged lines
291 291
                    u.new_state.is_zero() && protected.as_deref() == Some(u.name.as_str())
292 292
                });
293 293
                let mut report = r.receive_push_report(bundle, updates, policy, tags)?;
294 +
                // The request named references, all refused here: it was no
295 +
                // push of objects alone, whatever the repository saw.
296 +
                if !refused.is_empty() {
297 +
                    report
298 +
                        .results
299 +
                        .retain(|r| r.name != oxy_core::repo::OBJECTS_RESULT);
300 +
                }
294 301
                for u in refused {
295 302
                    report.results.push(oxy_core::protocol::RefResult {
296 303
                        name: u.name.clone(),
…
125 unchanged lines
modified docs/spec.md +2 −0
…
779 unchanged lines
780 780
781 781
On push, the server MUST, in order: verify that every change has a valid signature or seal from a key embedded in policy and satisfies the author-or-delegate rule (§14.4), verify the attestation for each reference, check that `old_state` matches its current state (compare-and-swap), check policy rules for the reference including `allow_conflicts`, check that every dependency of every change is either included or already stored, and only then update references atomically. A failure rejects the whole push.
782 782
783 +
**A push in parts.** A push may carry no reference update and no tag: its changes and objects alone, which the server validates as above and stores, reporting the outcome as one result named `objects` (`ok`, and as its reason how many changes it stored or why it refused them). A client sends a long history this way, in dependency order, in parts of about `push.batch-bytes` (default 256 MiB) of changes and their texts and blobs, and the last part carries the reference updates, so that neither side holds the whole history at once and no request passes the server's body limit. A part already stored is harmless if the push stops, and a server MAY collect its changes under §21 if no reference ever reaches them.
784 +
783 785
**Deleting a reference.** An update whose `new_state` is the zero ObjectRef deletes the bookmark (`oxy push REMOTE --delete NAME`). The bookmark MUST exist at `old_state`. A server MUST refuse to delete its default bookmark: in a bare repository that is the host's to name (the reference server's is the repository's configured bookmark, a forge's its default-bookmark setting), and in a non-bare repository it is the checked-out bookmark. The update is attested like any other: under a policy the attestation is required and its key MUST be authorized for the reference. The deletion's attestation stays the reference's latest, so an older attestation cannot be replayed to bring the bookmark back. The first attestation of a reference the remote does not list takes the client's clock in milliseconds as its serial, which is past the serial of any deleted namesake; later ones increment it. Changes of a deleted bookmark stay until unreachable (§21).
784 786
785 787
## 17. Partial, sparse, and shallow repositories
…
267 unchanged lines
modified src/cli/sync.rs +5 −4
…
640 unchanged lines
641 641
    let repo = open()?;
642 642
    let remote = remote_name(&repo, remote);
643 643
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
644 -
    let results = match bookmark {
645 -
        Some(b) => repo.push_bookmark(&remote, b, force)?,
646 -
        None => repo.push(&remote, force)?,
647 -
    };
644 +
    let bookmark_name = bookmark.map_or_else(|| repo.config.bookmark.clone(), String::from);
645 +
    // A long push goes in parts: the spinner says which.
646 +
    let results = repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| {
647 +
        sp.set(&format!("Pushing to {remote}… {m}"))
648 +
    })?;
648 649
    sp.clear();
649 650
    if as_json {
650 651
        println!(
…
450 unchanged lines