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 |