OXYGENHUB

A shallow clone fetches its checkpoint in parts (refs #137) 80b069facc69

checkpoint-index answers with the checkpoint and its history only; the
client asks for the graph blobs it lacks with objects, 2048 at a time,
so neither side holds a big state's whole answer (about 2 GB for VS
Code). objects reads in pack order. A server without checkpoint-index
answers 404 and the client falls back to checkpoint.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e4264085eda8
When
· 2026-10-26 10:57 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
80b069facc699214ba6ae4ad9f9c943df79f1089d3502c4b0fe19082098b3252
Depends on
2452b4407ab7 252e8cb23fbc 2863456a0f6d 5b9c6ad45f6e 750251b21a96 75167672ebcd 7bd805ffb418 8fd78eb52ad2 982864068a59 9f9e72b93e4e b3c5a59a875a cb8b8082fa73 e02bf0cd0533 e4595038502a f3811eb20bea ff0af1ee299d
Depended on by
fa53a2849de8 78b49d4d01f0 7a3cc5149a14 c0d4075cadc0 b5e98094eb8f 642fa213de80
In bookmarks
identical-sides main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.8.0 v0.9.0
Edits
27 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A shallow clone fetches its checkpoint in parts (#137).** The checkpoint of a big state came as one answer, the whole state's graph blobs in one body (about 2 GB for the VS Code history), built and held whole on both sides. A server now answers `checkpoint-index` (the checkpoint and its history, no graphs), and the client fetches the graph blobs it lacks with `objects`, 2,048 at a time; `objects` reads them in pack order. An older server answers 404 and gets the one-piece request as before.
6 +
5 7
- **A keyless agent follows its owner's newest identity (#82).** Every delegation rotates its owner's identity, so the `author.key` an agent's home was given goes stale, and its changes named an identity two rotations old (verifying only because a grant from before a rotation still counts). With `author.for = ACCOUNT` in the home, the hub sends the account's newest linked identity with each certificate, with its chain back to the grant's key; oxy keeps it in the home (`keys/author.for`), stores the chain beside the change so the grant checks, and authors as it from then on. A repository's own `author.key` pin still wins.
6 8
7 9
- **Changelogs stop conflicting (#145).** A file marked `merge=union` in `.oxyattributes` (`CHANGELOG.md merge=union`) has its adjacent insertions settled by the checkout itself: both sides kept, the earlier change's first, as `oxy resolve --both` does, and the next commit records the resolution. Only adjacent insertions: where two changes changed the same lines, the markers stay. Oxygen's own changelog is marked so.
…
281 unchanged lines
modified crates/oxy-core/src/protocol.rs +22 −0
…
208 unchanged lines
209 209
    /// A checkpoint of exactly `heads` (spec §16.1 `checkpoint`): the
210 210
    /// checkpoint object, its payload blob, and the blob's chunks.
211 211
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle>;
212 +
    /// A checkpoint of exactly `heads` without its graph blobs (spec §16.1
213 +
    /// `checkpoint-index`, oxygen#137), which the client then asks for in
214 +
    /// parts with `objects`. A remote that cannot answer it sends the
215 +
    /// whole checkpoint, which does as well.
216 +
    fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> {
217 +
        self.checkpoint(heads)
218 +
    }
212 219
    /// The changes within `depth` dependency steps of `heads` (spec §17).
213 220
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>>;
214 221
    /// Push changes, bookmark updates, new tags, and optionally a newer
…
45 unchanged lines
260 267
    }
261 268
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> {
262 269
        self.repo.checkpoint_bundle_for(heads)
270 +
    }
271 +
    fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> {
272 +
        self.repo.checkpoint_index_for(heads)
263 273
    }
264 274
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
265 275
        self.repo.shallow_changes(heads, depth)
…
426 unchanged lines
692 702
            self.call(true, "checkpoint", Some(&wire::encode_hashes(heads)))?
693 703
        };
694 704
        let _t = crate::timing::phase("remote: decode the checkpoint");
705 +
        wire::decode_bundle(&body)
706 +
    }
707 +
    fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> {
708 +
        let body = {
709 +
            let _t = crate::timing::phase("remote: checkpoint index");
710 +
            match self.call(true, "checkpoint-index", Some(&wire::encode_hashes(heads))) {
711 +
                Ok(b) => b,
712 +
                // A server from before checkpoint-index: the whole answer.
713 +
                Err(RepoError::Msg(m)) if m.contains("HTTP 404") => return self.checkpoint(heads),
714 +
                Err(e) => return Err(e),
715 +
            }
716 +
        };
695 717
        wire::decode_bundle(&body)
696 718
    }
697 719
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
…
1077 unchanged lines
modified crates/oxy-core/src/repo/checkpoint.rs +35 −13
…
112 unchanged lines
113 113
        write_atomic(&self.checkpoint_pointer(bookmark), r.to_hex().as_bytes())
114 114
    }
115 115
116 -
    /// A bundle holding a checkpoint of exactly `heads` with its payload
117 -
    /// (spec §16.1 `checkpoint`); the current bookmark checkpoint is reused
118 -
    /// when it matches, otherwise one is made and, when `heads` are the
119 -
    /// bookmark's, kept.
120 -
    pub fn checkpoint_bundle_for(&self, heads: &[ChangeHash]) -> Result<Bundle> {
121 -
        let t_find = crate::timing::phase("checkpoint: find or make");
116 +
    /// The checkpoint of exactly `heads`: the bookmark's latest when it
117 +
    /// is that state, else one made now (kept as the bookmark's when the
118 +
    /// bookmark is at `heads`).
119 +
    fn checkpoint_ref_for(&self, heads: &[ChangeHash]) -> Result<ObjectRef> {
120 +
        let _t = crate::timing::phase("checkpoint: find or make");
122 121
        let wanted = StateObject::new(heads.to_vec()).heads;
123 122
        let bookmark = self.config.bookmark.clone();
124 123
        let current = self.bookmark_heads()?;
125 -
        let r = match self.latest_checkpoint(&bookmark)? {
124 +
        Ok(match self.latest_checkpoint(&bookmark)? {
126 125
            Some((r, cp)) if cp.heads == wanted => r,
127 126
            _ => {
128 127
                let state = self.load_state(&wanted)?;
…
9 unchanged lines
138 137
                    self.store.put(ObjectType::Checkpoint, &cp.encode())?
139 138
                }
140 139
            }
141 -
        };
142 -
        drop(t_find);
143 -
        let _t = crate::timing::phase("checkpoint: gather its objects");
140 +
        })
141 +
    }
142 +
143 +
    /// A checkpoint of exactly `heads` without its graph blobs (spec
144 +
    /// §16.1 `checkpoint-index`, oxygen#137): the checkpoint object, its
145 +
    /// payload blob and the blob's chunks. The client asks for the graph
146 +
    /// blobs the payload names in parts, with `objects`, so neither side
147 +
    /// holds a big state's whole answer at once.
148 +
    pub fn checkpoint_index_for(&self, heads: &[ChangeHash]) -> Result<Bundle> {
149 +
        let r = self.checkpoint_ref_for(heads)?;
144 150
        let cp = Checkpoint::decode(&self.store.get_typed(&r, ObjectType::Checkpoint)?)?;
145 151
        let mut bundle = Bundle::default();
146 -
        for c in Blob::decode(&self.store.get_typed(&cp.payload, ObjectType::Blob)?)?.chunks {
147 -
            bundle.objects.push(self.store.get(&c)?);
152 +
        let chunks = Blob::decode(&self.store.get_typed(&cp.payload, ObjectType::Blob)?)?.chunks;
153 +
        for got in self.store.get_many(&chunks) {
154 +
            bundle.objects.push(got?);
148 155
        }
149 156
        bundle.objects.push(self.store.get(&cp.payload)?);
157 +
        bundle.objects.push((ObjectType::Checkpoint, cp.encode()));
158 +
        Ok(bundle)
159 +
    }
160 +
161 +
    /// A checkpoint of exactly `heads` with its graph blobs and their
162 +
    /// chunks, in one answer (spec §16.1 `checkpoint`): what a client
163 +
    /// older than `checkpoint-index` asks for.
164 +
    pub fn checkpoint_bundle_for(&self, heads: &[ChangeHash]) -> Result<Bundle> {
165 +
        let mut bundle = self.checkpoint_index_for(heads)?;
166 +
        let _t = crate::timing::phase("checkpoint: gather its objects");
167 +
        let cp = match bundle.objects.last() {
168 +
            Some((ObjectType::Checkpoint, body)) => Checkpoint::decode(body)?,
169 +
            _ => return fail("a checkpoint index without its checkpoint"),
170 +
        };
150 171
        // The graph blobs and their chunks in two parallel reads (#137): a
151 172
        // big state's are tens of thousands, and one by one they took six
152 173
        // minutes for VS Code.
…
9 unchanged lines
162 183
            }
163 184
            chunks.extend(Blob::decode(body)?.chunks);
164 185
        }
186 +
        let last = bundle.objects.pop();
165 187
        for got in self.store.get_many(&chunks) {
166 188
            bundle.objects.push(got?);
167 189
        }
168 190
        bundle.objects.extend(blobs);
169 -
        bundle.objects.push((ObjectType::Checkpoint, cp.encode()));
191 +
        bundle.objects.extend(last);
170 192
        Ok(bundle)
171 193
    }
172 194
…
80 unchanged lines
modified crates/oxy-core/src/repo/strata.rs +4 −0
…
419 unchanged lines
420 420
        self.with_mirror(|m| m.checkpoint_bundle_for(heads))
421 421
    }
422 422
423 +
    fn checkpoint_index(&self, heads: &[ChangeHash]) -> Result<Bundle> {
424 +
        self.with_mirror(|m| m.checkpoint_index_for(heads))
425 +
    }
426 +
423 427
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
424 428
        self.with_mirror(|m| m.shallow_changes(heads, depth))
425 429
    }
…
941 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +63 −18
…
59 unchanged lines
60 60
/// not a reference.
61 61
pub const OBJECTS_RESULT: &str = "objects";
62 62
63 +
/// Graph blobs asked for in one `objects` request when a shallow clone
64 +
/// fetches its checkpoint in parts (#137): their chunk lists and chunks,
65 +
/// a few MiB a request rather than one answer of gigabytes.
66 +
const CHECKPOINT_PART: usize = 2048;
67 +
63 68
impl Repo {
64 69
    // ------------------------------------------------------------ serving (spec §16)
65 70
…
219 unchanged lines
285 290
286 291
    /// Objects by reference for the `objects` endpoint; a blob brings its chunks.
287 292
    pub fn objects_for(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
288 -
        let mut out = Vec::new();
289 -
        for r in refs {
290 -
            let (ty, body) = match self.store.get(r) {
291 -
                Ok(x) => x,
292 -
                Err(StoreError::NotFound(_)) => continue,
293 +
        // In two reads in pack order (#137): a checkpoint's graph blobs come
294 +
        // this way in parts, tens of thousands for a big state.
295 +
        let mut found = Vec::with_capacity(refs.len());
296 +
        for got in self.store.get_many(refs) {
297 +
            match got {
298 +
                Ok(x) => found.push(x),
299 +
                Err(StoreError::NotFound(_)) => {}
293 300
                Err(e) => return Err(e.into()),
294 -
            };
295 -
            if ty == ObjectType::Blob {
296 -
                for c in Blob::decode(&body)?.chunks {
297 -
                    if let Ok((cty, cb)) = self.store.get(&c) {
298 -
                        out.push((cty, cb));
299 -
                    }
300 -
                }
301 +
            }
302 +
        }
303 +
        let mut chunks = Vec::new();
304 +
        for (ty, body) in &found {
305 +
            if *ty == ObjectType::Blob {
306 +
                chunks.extend(Blob::decode(body)?.chunks);
307 +
            }
308 +
        }
309 +
        let mut out = Vec::with_capacity(found.len() + chunks.len());
310 +
        for got in self.store.get_many(&chunks) {
311 +
            match got {
312 +
                Ok(x) => out.push(x),
313 +
                Err(StoreError::NotFound(_)) => {}
314 +
                Err(e) => return Err(e.into()),
301 315
            }
302 -
            out.push((ty, body));
303 316
        }
317 +
        out.extend(found);
304 318
        Ok(out)
305 319
    }
306 320
…
1566 unchanged lines
1873 1887
        if r.heads.is_empty() {
1874 1888
            return Ok(0);
1875 1889
        }
1876 -
        // Base: the remote's checkpoint of exactly these heads.
1890 +
        // Base: the remote's checkpoint of exactly these heads, without its
1891 +
        // graph blobs, which come in parts below (#137).
1877 1892
        let cp_bundle = {
1878 1893
            let _t = crate::timing::phase("shallow: checkpoint request");
1879 -
            remote.checkpoint(&r.heads)?
1894 +
            remote.checkpoint_index(&r.heads)?
1880 1895
        };
1881 1896
        // In one batch: a big state's checkpoint is tens of thousands of
1882 1897
        // graph blobs and chunks, which stored one by one were each written
…
16 unchanged lines
1899 1914
        let Some(cp_ref) = cp_ref else {
1900 1915
            return fail(format!("{remote_name} sent no checkpoint"));
1901 1916
        };
1917 +
        // The graph blobs the payload names, with their chunks, a part at
1918 +
        // a time (#137): one answer for a big state was gigabytes, held
1919 +
        // whole on both sides. A remote that sent the whole checkpoint has
1920 +
        // left none to ask for.
1921 +
        let cp = crate::objects::Checkpoint::decode(
1922 +
            &self.store.get_typed(&cp_ref, ObjectType::Checkpoint)?,
1923 +
        )?;
1924 +
        let graphs: Vec<ObjectRef> = self
1925 +
            .checkpoint_graph_blobs(&cp)?
1926 +
            .into_iter()
1927 +
            .filter(|b| !self.store.has(b))
1928 +
            .collect();
1929 +
        {
1930 +
            let _t = crate::timing::phase("shallow: the checkpoint's graphs, in parts");
1931 +
            for part in graphs.chunks(CHECKPOINT_PART) {
1932 +
                let objects = remote.objects(part)?;
1933 +
                let objects: Vec<(ObjectType, &[u8])> = objects
1934 +
                    .iter()
1935 +
                    .filter(|(ty, _)| matches!(ty, ObjectType::Blob | ObjectType::Chunk))
1936 +
                    .map(|(ty, body)| (*ty, body.as_slice()))
1937 +
                    .collect();
1938 +
                let batch = self.store.begin_batch()?;
1939 +
                let stored = self.store.put_many(&objects);
1940 +
                batch.finish()?;
1941 +
                stored?;
1942 +
            }
1943 +
            if let Some(b) = graphs.iter().find(|b| !self.store.has(b)) {
1944 +
                return fail(format!(
1945 +
                    "{remote_name} did not send graph blob {}",
1946 +
                    b.short(12)
1947 +
                ));
1948 +
            }
1949 +
        }
1902 1950
        self.install_checkpoint(&bookmark, &cp_ref)?;
1903 1951
        let _t_blobs = crate::timing::phase("shallow: the binary files' blobs");
1904 1952
        // The blobs of the state's opaque files come with no change when the
1905 1953
        // window holds none of the changes that set them (#137): fetch them
1906 1954
        // now, or under `filter.max-blob` leave them to be fetched on read.
1907 -
        let cp = crate::objects::Checkpoint::decode(
1908 -
            &self.store.get_typed(&cp_ref, ObjectType::Checkpoint)?,
1909 -
        )?;
1910 1955
        let base = State::from_checkpoint_history(&self.read_blob(&cp.payload)?)?;
1911 1956
        let mut missing: Vec<ObjectRef> = base
1912 1957
            .opaque_nodes()
…
934 unchanged lines
modified crates/oxy-server/src/lib.rs +9 −0
…
150 unchanged lines
151 151
    Fetch,
152 152
    Objects,
153 153
    Checkpoint,
154 +
    CheckpointIndex,
154 155
    Shallow,
155 156
    Push,
156 157
}
…
7 unchanged lines
164 165
            "fetch" => Endpoint::Fetch,
165 166
            "objects" => Endpoint::Objects,
166 167
            "checkpoint" => Endpoint::Checkpoint,
168 +
            "checkpoint-index" => Endpoint::CheckpointIndex,
167 169
            "shallow" => Endpoint::Shallow,
168 170
            "push" => Endpoint::Push,
169 171
            _ => return None,
…
8 unchanged lines
178 180
            Endpoint::Fetch => "fetch",
179 181
            Endpoint::Objects => "objects",
180 182
            Endpoint::Checkpoint => "checkpoint",
183 +
            Endpoint::CheckpointIndex => "checkpoint-index",
181 184
            Endpoint::Shallow => "shallow",
182 185
            Endpoint::Push => "push",
183 186
        }
…
139 unchanged lines
323 326
                let _t = oxy_core::timing::phase("serve: encode the checkpoint");
324 327
                ok(wire::encode_bundle(&bundle))
325 328
            }
329 +
            Endpoint::CheckpointIndex => {
330 +
                let heads = wire::decode_hashes(&body)?;
331 +
                let lock = self.lock_for(name);
332 +
                let _guard = lock.lock().unwrap();
333 +
                ok(wire::encode_bundle(&r.checkpoint_index_for(&heads)?))
334 +
            }
326 335
            Endpoint::Shallow => {
327 336
                let (heads, depth) = wire::decode_shallow(&body)?;
328 337
                ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))
…
184 unchanged lines
modified docs/spec.md +2 −1
…
904 unchanged lines
905 905
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
906 906
| `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size; also when it names no change, the bundle carries the identities of the keys that signed the attestations served with `refs` (each bookmark's latest and each tag's) and, for a short-lived key, its certificate and its issuer's identity (§14.7), each once, so a client can check an attestation whose signer signed none of the changes it receives, such as a delegate's or a keyless agent's bookmark move (oxygenhub#266) |
907 907
| `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks |
908 -
| `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks |
908 +
| `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks, with the payload's graph blobs and their chunks (§11) |
909 +
| `checkpoint-index` request | as `checkpoint`, without the graph blobs: the client asks for those it lacks with `objects`, in parts (oxygen#137), so neither side holds a big state's whole answer; a server without it answers 404 and the client falls back to `checkpoint` |
909 910
| `shallow` request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`, the changes within `depth` steps of the heads, dependencies first |
910 911
| `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null, 3: [tag]}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update and `tags/<name>` each tag |
911 912
…
280 unchanged lines