Lazy blobs: clone --max-blob / filter.max-blob, absent-blob registry fetched on first read, objects endpoint (spec 0.6e) b3c5a59a875a
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 12:56 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b3c5a59a875ad8c743bf5622a247d362654f96581948c75a3af2ec8082f76073- Depends on
- 096f447ccda0 135212de5370 252e8cb23fbc 2b5ee371060e 5b9c6ad45f6e 750251b21a96 83e0677b07fe 982864068a59 ba160f60219e cfb7d414eae6 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 2f0b4b0651ea 80b069facc69 570e35310f31 e30c081a5064 f3811eb20bea d3b270909c78 d36ba2f79f95 cb8b8082fa73 d069701a4997 827281eed2ed 14edbaaf5291 ff1f62fee654 091fef80755d a07f60a37fb2 2c75672fb2fe afaba5f1306d d42369abf180 3e5772ccf9da 387335ff7f4d 75167672ebcd e02bf0cd0533 6d3eefbce2f0 2863456a0f6d 7421309f5372
- 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.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 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
- 44 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified crates/oxy-core/src/protocol.rs +34 −5
| … | 87 unchanged lines |
||
| 88 | 88 | fn refs(&self) -> Result<RefsResponse>; |
|
| 89 | 89 | /// Changes in the closure of `want` but not of `have`, in dependency order. |
|
| 90 | 90 | fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>>; |
|
| 91 | - | fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle>; |
|
| 91 | + | /// The changes with their signatures, seals, identities, and blobs; blobs |
|
| 92 | + | /// larger than `max_blob` bytes are left out for lazy fetching (spec §13.5). |
|
| 93 | + | fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle>; |
|
| 94 | + | /// Individual objects by reference (spec §16.1 `objects`); a blob comes |
|
| 95 | + | /// with its chunks. |
|
| 96 | + | fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>>; |
|
| 92 | 97 | /// Push changes, bookmark updates, and optionally a newer policy. The |
|
| 93 | 98 | /// policy is applied first; a result named `policy` reports it. |
|
| 94 | 99 | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>>; |
|
| … | 22 unchanged lines |
||
| 117 | 122 | fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> { |
|
| 118 | 123 | self.repo.negotiate(have, want) |
|
| 119 | 124 | } |
|
| 120 | - | fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle> { |
|
| 121 | - | self.repo.bundle_for(changes) |
|
| 125 | + | fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> { |
|
| 126 | + | self.repo.bundle_for_filtered(changes, max_blob) |
|
| 127 | + | } |
|
| 128 | + | fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> { |
|
| 129 | + | self.repo.objects_for(refs) |
|
| 122 | 130 | } |
|
| 123 | 131 | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> { |
|
| 124 | 132 | self.repo.receive_push(bundle, updates, policy) |
|
| … | 85 unchanged lines |
||
| 210 | 218 | fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> { |
|
| 211 | 219 | wire::decode_hashes(&self.call(true, "negotiate", Some(&wire::encode_negotiate(have, want)))?) |
|
| 212 | 220 | } |
|
| 213 | - | fn fetch(&self, changes: &[ChangeHash]) -> Result<Bundle> { |
|
| 214 | - | wire::decode_bundle(&self.call(true, "fetch", Some(&wire::encode_hashes(changes)))?) |
|
| 221 | + | fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> { |
|
| 222 | + | wire::decode_bundle(&self.call(true, "fetch", Some(&wire::encode_fetch(changes, max_blob)))?) |
|
| 215 | 223 | } |
|
| 224 | + | fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> { |
|
| 225 | + | Ok(wire::decode_bundle(&self.call(true, "objects", Some(&wire::encode_hashes(refs)))?)?.objects) |
|
| 226 | + | } |
|
| 216 | 227 | fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> { |
|
| 217 | 228 | wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref())))?) |
|
| 218 | 229 | } |
|
| … | 190 unchanged lines |
||
| 409 | 420 | Ok((hashes_from(field(m, 0, "have")?)?, hashes_from(field(m, 1, "want")?)?)) |
|
| 410 | 421 | } |
|
| 411 | 422 | ||
| 423 | + | /// `fetch` request: `{0: [changes], 1: max blob size or null}`. |
|
| 424 | + | pub fn encode_fetch(hs: &[ChangeHash], max_blob: Option<u64>) -> Vec<u8> { |
|
| 425 | + | cbor::encode(&Value::map([(0, hashes_value(hs)), (1, max_blob.map(Value::Uint).unwrap_or(Value::Null))])) |
|
| 426 | + | } |
|
| 427 | + | pub fn decode_fetch(bytes: &[u8]) -> Result<(Vec<ChangeHash>, Option<u64>)> { |
|
| 428 | + | let v = cbor::decode(bytes).map_err(ObjectError::from)?; |
|
| 429 | + | let m = map(&v, "fetch")?; |
|
| 430 | + | let max = match m.get(&1) { |
|
| 431 | + | Some(x) if !x.is_null() => Some(o::uint(x, "max blob")?), |
|
| 432 | + | _ => None, |
|
| 433 | + | }; |
|
| 434 | + | Ok((hashes_from(field(m, 0, "changes")?)?, max)) |
|
| 435 | + | } |
|
| 436 | + | ||
| 412 | 437 | pub fn encode_hashes(hs: &[ChangeHash]) -> Vec<u8> { |
|
| 413 | 438 | cbor::encode(&Value::map([(0, hashes_value(hs))])) |
|
| 414 | 439 | } |
|
| … | 117 unchanged lines |
||
| 532 | 557 | let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(&[ObjectRef([1; 32])], &[ObjectRef([2; 32])])).unwrap(); |
|
| 533 | 558 | assert_eq!((h, w), (vec![ObjectRef([1; 32])], vec![ObjectRef([2; 32])])); |
|
| 534 | 559 | assert_eq!(wire::decode_hashes(&wire::encode_hashes(&[c.hash()])).unwrap(), vec![c.hash()]); |
|
| 560 | + | assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[c.hash()], Some(4096))).unwrap(), (vec![c.hash()], Some(4096))); |
|
| 561 | + | assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[], None)).unwrap(), (vec![], None)); |
|
| 562 | + | // An old-style fetch request without the filter field still decodes. |
|
| 563 | + | assert_eq!(wire::decode_fetch(&wire::encode_hashes(&[c.hash()])).unwrap(), (vec![c.hash()], None)); |
|
| 535 | 564 | ||
| 536 | 565 | let up = RefUpdate { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), force: true, attestation: Some(SignedAttest { attest: att, sigs: vec![sig] }) }; |
|
| 537 | 566 | let sp = SignedPolicy { policy, sigs: vec![psig] }; |
|
| … | 11 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +17 −0
| … | 28 unchanged lines |
||
| 29 | 29 | pub gc_grace_days: u64, |
|
| 30 | 30 | /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11). |
|
| 31 | 31 | pub checkpoint_interval: u64, |
|
| 32 | + | /// `filter.max-blob`: blobs larger than this many bytes are fetched lazily (spec §17). |
|
| 33 | + | pub max_blob: Option<u64>, |
|
| 32 | 34 | } |
|
| 33 | 35 | ||
| 34 | 36 | impl Config { |
|
| … | 12 unchanged lines |
||
| 47 | 49 | op_retention_days: 90, |
|
| 48 | 50 | gc_grace_days: 14, |
|
| 49 | 51 | checkpoint_interval: 256, |
|
| 52 | + | max_blob: None, |
|
| 50 | 53 | } |
|
| 51 | 54 | } |
|
| 52 | 55 | ||
| … | 45 unchanged lines |
||
| 98 | 101 | "workspace.bookmark" => self.bookmark = value.into(), |
|
| 99 | 102 | "core.op-retention" => self.op_retention_days = parse_days(value)?, |
|
| 100 | 103 | "core.gc-grace" => self.gc_grace_days = parse_days(value)?, |
|
| 104 | + | "filter.max-blob" => self.max_blob = if value.is_empty() { None } else { Some(parse_size(value)?) }, |
|
| 101 | 105 | "core.checkpoint-interval" => { |
|
| 102 | 106 | self.checkpoint_interval = value.parse().map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?; |
|
| 103 | 107 | } |
|
| … | 52 unchanged lines |
||
| 156 | 160 | } |
|
| 157 | 161 | } |
|
| 158 | 162 | ||
| 163 | + | /// A byte count with an optional `K`, `M`, or `G` suffix (binary units). |
|
| 164 | + | pub fn parse_size(v: &str) -> Result<u64> { |
|
| 165 | + | let v = v.trim(); |
|
| 166 | + | let (num, mult) = match v.chars().last() { |
|
| 167 | + | Some('K' | 'k') => (&v[..v.len() - 1], 1u64 << 10), |
|
| 168 | + | Some('M' | 'm') => (&v[..v.len() - 1], 1u64 << 20), |
|
| 169 | + | Some('G' | 'g') => (&v[..v.len() - 1], 1u64 << 30), |
|
| 170 | + | _ => (v, 1), |
|
| 171 | + | }; |
|
| 172 | + | let n: u64 = num.trim().parse().map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?; |
|
| 173 | + | Ok(n * mult) |
|
| 174 | + | } |
|
| 175 | + | ||
| 159 | 176 | /// A count of days, optionally suffixed `d`. |
|
| 160 | 177 | fn parse_days(v: &str) -> Result<u64> { |
|
| 161 | 178 | v.trim_end_matches('d').parse().map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}"))) |
|
| … | 26 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +6 −1
| … | 65 unchanged lines |
||
| 66 | 66 | mod tests; |
|
| 67 | 67 | ||
| 68 | 68 | pub use checkout::CheckoutReport; |
|
| 69 | - | pub use config::{read_config_file, Config}; |
|
| 69 | + | pub use config::{parse_size, read_config_file, Config}; |
|
| 70 | 70 | pub use gc::GcReport; |
|
| 71 | 71 | pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT}; |
|
| 72 | 72 | pub use git::{GitExportReport, GitImportReport}; |
|
| … | 373 unchanged lines |
||
| 446 | 446 | Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?) |
|
| 447 | 447 | } |
|
| 448 | 448 | ||
| 449 | + | /// Read a blob's content, fetching it from the remote that withheld it |
|
| 450 | + | /// when it is lazily absent (spec §13.5). |
|
| 449 | 451 | pub fn read_blob(&self, r: &ObjectRef) -> Result<Vec<u8>> { |
|
| 452 | + | if !self.store.has(r) && self.lazy_remote(r).is_some() { |
|
| 453 | + | self.fetch_lazy_blob(r)?; |
|
| 454 | + | } |
|
| 450 | 455 | let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?; |
|
| 451 | 456 | let mut out = Vec::with_capacity(blob.length as usize); |
|
| 452 | 457 | for c in &blob.chunks { |
|
| … | 137 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +98 −2
| … | 103 unchanged lines |
||
| 104 | 104 | /// Bundle changes (in the given dependency order) with their signatures, |
|
| 105 | 105 | /// seals, identities, blobs, chunks. |
|
| 106 | 106 | pub fn bundle_for(&self, changes: &[ChangeHash]) -> Result<Bundle> { |
|
| 107 | + | self.bundle_for_filtered(changes, None) |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// Objects by reference for the `objects` endpoint; a blob brings its chunks. |
|
| 111 | + | pub fn objects_for(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> { |
|
| 112 | + | let mut out = Vec::new(); |
|
| 113 | + | for r in refs { |
|
| 114 | + | let (ty, body) = match self.store.get(r) { |
|
| 115 | + | Ok(x) => x, |
|
| 116 | + | Err(StoreError::NotFound(_)) => continue, |
|
| 117 | + | Err(e) => return Err(e.into()), |
|
| 118 | + | }; |
|
| 119 | + | if ty == ObjectType::Blob { |
|
| 120 | + | for c in Blob::decode(&body)?.chunks { |
|
| 121 | + | if let Ok((cty, cb)) = self.store.get(&c) { |
|
| 122 | + | out.push((cty, cb)); |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | out.push((ty, body)); |
|
| 127 | + | } |
|
| 128 | + | Ok(out) |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | /// Like `bundle_for`, leaving out blobs larger than `max_blob` bytes |
|
| 132 | + | /// (and their chunks) so the receiver fetches them lazily (spec §13.5). |
|
| 133 | + | pub fn bundle_for_filtered(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> { |
|
| 107 | 134 | let mut bundle = Bundle::default(); |
|
| 108 | 135 | let mut objects: BTreeSet<ObjectRef> = BTreeSet::new(); |
|
| 109 | 136 | let mut seals: BTreeSet<ObjectRef> = BTreeSet::new(); |
|
| … | 13 unchanged lines |
||
| 123 | 150 | if let Edit::Replace { old, new, .. } = e { |
|
| 124 | 151 | for b in [old, new].into_iter().flatten() { |
|
| 125 | 152 | if self.store.has(b) { |
|
| 126 | - | objects.insert(*b); |
|
| 127 | 153 | if let Ok(blob) = Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?) { |
|
| 154 | + | if max_blob.is_some_and(|m| blob.length > m) { |
|
| 155 | + | continue; |
|
| 156 | + | } |
|
| 157 | + | objects.insert(*b); |
|
| 128 | 158 | objects.extend(blob.chunks.iter().copied()); |
|
| 129 | 159 | } |
|
| 130 | 160 | } |
|
| … | 239 unchanged lines |
||
| 370 | 400 | write_atomic(&p, serial.to_string().as_bytes()) |
|
| 371 | 401 | } |
|
| 372 | 402 | ||
| 403 | + | fn lazy_dir(&self) -> PathBuf { |
|
| 404 | + | self.oxy.join("lazy") |
|
| 405 | + | } |
|
| 406 | + | ||
| 407 | + | /// Remember that `blob` was withheld by `remote` (spec §13.5). |
|
| 408 | + | fn register_lazy(&self, blob: &ObjectRef, remote: &str) -> Result<()> { |
|
| 409 | + | fs::create_dir_all(self.lazy_dir())?; |
|
| 410 | + | write_atomic(&self.lazy_dir().join(blob.to_hex()), remote.as_bytes()) |
|
| 411 | + | } |
|
| 412 | + | ||
| 413 | + | /// Which remote promised an absent blob, if any. |
|
| 414 | + | pub fn lazy_remote(&self, blob: &ObjectRef) -> Option<String> { |
|
| 415 | + | fs::read_to_string(self.lazy_dir().join(blob.to_hex())).ok().map(|s| s.trim().to_string()) |
|
| 416 | + | } |
|
| 417 | + | ||
| 418 | + | /// Fetch an absent blob (and its chunks) from the remote that promised it. |
|
| 419 | + | pub(crate) fn fetch_lazy_blob(&self, blob: &ObjectRef) -> Result<()> { |
|
| 420 | + | let Some(remote_name) = self.lazy_remote(blob) else { |
|
| 421 | + | return Err(RepoError::Store(StoreError::NotFound(*blob))); |
|
| 422 | + | }; |
|
| 423 | + | let remote = self.open_named_remote(&remote_name)?; |
|
| 424 | + | let objects = remote.objects(&[*blob])?; |
|
| 425 | + | for (ty, body) in objects { |
|
| 426 | + | if matches!(ty, ObjectType::Blob | ObjectType::Chunk) { |
|
| 427 | + | self.store.put(ty, &body)?; |
|
| 428 | + | } |
|
| 429 | + | } |
|
| 430 | + | if !self.store.has(blob) { |
|
| 431 | + | return fail(format!("{remote_name} did not send blob {}", blob.short(12))); |
|
| 432 | + | } |
|
| 433 | + | let _ = fs::remove_file(self.lazy_dir().join(blob.to_hex())); |
|
| 434 | + | Ok(()) |
|
| 435 | + | } |
|
| 436 | + | ||
| 437 | + | /// After importing from `remote`, record every blob the changes reference |
|
| 438 | + | /// that the store lacks as lazily available from that remote. |
|
| 439 | + | fn register_missing_blobs(&self, changes: &[ChangeHash], remote: &str) -> Result<usize> { |
|
| 440 | + | let mut n = 0; |
|
| 441 | + | for h in changes { |
|
| 442 | + | let c = self.store.get_change(h)?; |
|
| 443 | + | for e in &c.edits { |
|
| 444 | + | if let Edit::Replace { old, new, .. } = e { |
|
| 445 | + | for b in [old, new].into_iter().flatten() { |
|
| 446 | + | if !self.store.has(b) { |
|
| 447 | + | self.register_lazy(b, remote)?; |
|
| 448 | + | n += 1; |
|
| 449 | + | } |
|
| 450 | + | } |
|
| 451 | + | } |
|
| 452 | + | } |
|
| 453 | + | } |
|
| 454 | + | Ok(n) |
|
| 455 | + | } |
|
| 456 | + | ||
| 373 | 457 | /// Every head this repository knows: bookmarks, remote tracking, working copy. |
|
| 374 | 458 | fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> { |
|
| 375 | 459 | let mut have: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| … | 52 unchanged lines |
||
| 428 | 512 | let mut view = op.view.clone(); |
|
| 429 | 513 | let have = self.known_heads(&view)?; |
|
| 430 | 514 | let mut report = Vec::new(); |
|
| 515 | + | let max_blob = self.config.max_blob; |
|
| 431 | 516 | for r in resp.refs { |
|
| 432 | 517 | let needed = remote.negotiate(&have, &r.heads)?; |
|
| 433 | - | let bundle = remote.fetch(&needed)?; |
|
| 518 | + | let bundle = remote.fetch(&needed, max_blob)?; |
|
| 434 | 519 | // Changes are not individually re-verified here: the bookmark's |
|
| 435 | 520 | // attestation (checked below) binds its whole state, and the |
|
| 436 | 521 | // receiver of the push verified the added changes at that time. |
|
| 437 | 522 | let imported = self.import_bundle(&bundle, false)?; |
|
| 523 | + | if max_blob.is_some() { |
|
| 524 | + | self.register_missing_blobs(&imported, remote_name)?; |
|
| 525 | + | } |
|
| 438 | 526 | for h in &r.heads { |
|
| 439 | 527 | if !self.store.has(h) { |
|
| 440 | 528 | return fail(format!("remote did not send head {}", h.short(12))); |
|
| … | 174 unchanged lines |
||
| 615 | 703 | /// Clone a remote into `dest`: same repository ID, `origin` remote, |
|
| 616 | 704 | /// bookmark set to the remote's, working copy checked out. |
|
| 617 | 705 | pub fn clone(url: &str, dest: &Path) -> Result<Repo> { |
|
| 706 | + | Self::clone_with(url, dest, None) |
|
| 707 | + | } |
|
| 708 | + | ||
| 709 | + | /// Clone, leaving blobs above `max_blob` bytes to be fetched on first read. |
|
| 710 | + | pub fn clone_with(url: &str, dest: &Path, max_blob: Option<u64>) -> Result<Repo> { |
|
| 618 | 711 | let options = RemoteOptions { |
|
| 619 | 712 | token: std::env::var("OXY_TOKEN").ok(), |
|
| 620 | 713 | ca: std::env::var("OXY_CA").ok().map(PathBuf::from), |
|
| … | 2 unchanged lines |
||
| 623 | 716 | let id = remote.repo_id()?; |
|
| 624 | 717 | let mut repo = Repo::init_with_id(dest, id)?; |
|
| 625 | 718 | repo.config.set("remote.origin.url", url)?; |
|
| 719 | + | if let Some(m) = max_blob { |
|
| 720 | + | repo.config.set("filter.max-blob", &m.to_string())?; |
|
| 721 | + | } |
|
| 626 | 722 | repo.save_config()?; |
|
| 627 | 723 | repo.fetch("origin")?; |
|
| 628 | 724 | let (op_ref, op) = repo.head_op()?; |
|
| … | 11 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +52 −0
| … | 1158 unchanged lines |
||
| 1159 | 1159 | assert_eq!(info.format("%A%D"), "-1"); |
|
| 1160 | 1160 | let _ = origin; |
|
| 1161 | 1161 | } |
|
| 1162 | + | ||
| 1163 | + | #[test] |
|
| 1164 | + | fn lazy_blobs_are_fetched_on_first_read() { |
|
| 1165 | + | hermetic(); |
|
| 1166 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1167 | + | let root = dir.path(); |
|
| 1168 | + | let origin_dir = root.join("origin"); |
|
| 1169 | + | let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home")); |
|
| 1170 | + | let url = origin_dir.to_string_lossy().to_string(); |
|
| 1171 | + | let big: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect(); // has NUL bytes: opaque |
|
| 1172 | + | write(&origin_dir, "big.bin", &big); |
|
| 1173 | + | write(&origin_dir, "small.bin", &[0u8, 1, 2]); |
|
| 1174 | + | write(&origin_dir, "t.txt", b"t\n"); |
|
| 1175 | + | let h = origin.commit("blobs", &[]).unwrap(); |
|
| 1176 | + | let big_ref = Repo::blob_ref(&big); |
|
| 1177 | + | ||
| 1178 | + | // Fetch only (no checkout) with a filter: the big blob stays absent. |
|
| 1179 | + | let a = Repo::init_with_id(&root.join("a"), origin.id().unwrap()).unwrap().with_home(root.join("home")); |
|
| 1180 | + | let a = { |
|
| 1181 | + | let mut a = a; |
|
| 1182 | + | a.config.set("remote.origin.url", &url).unwrap(); |
|
| 1183 | + | a.config.set("filter.max-blob", "100K").unwrap(); |
|
| 1184 | + | a.save_config().unwrap(); |
|
| 1185 | + | a |
|
| 1186 | + | }; |
|
| 1187 | + | assert_eq!(a.config.max_blob, Some(100 * 1024)); |
|
| 1188 | + | a.fetch("origin").unwrap(); |
|
| 1189 | + | assert!(a.store().has(&h)); |
|
| 1190 | + | assert!(!a.store().has(&big_ref), "withheld"); |
|
| 1191 | + | assert!(a.store().has(&Repo::blob_ref(&[0u8, 1, 2])), "small blob came along"); |
|
| 1192 | + | assert_eq!(a.lazy_remote(&big_ref).as_deref(), Some("origin")); |
|
| 1193 | + | ||
| 1194 | + | // status and commit do not need it: adopt the state, edit a text file, commit. |
|
| 1195 | + | let (op_ref, op) = a.head_op().unwrap(); |
|
| 1196 | + | let mut view = op.view.clone(); |
|
| 1197 | + | view.bookmarks.insert("main".into(), a.store().put_state(&StateObject::new(vec![h])).unwrap()); |
|
| 1198 | + | a.record_op(&[op_ref], "adopt", view).unwrap(); |
|
| 1199 | + | // Checkout reads the blob lazily, which fetches it. |
|
| 1200 | + | a.checkout(&[h], true, "checkout").unwrap(); |
|
| 1201 | + | assert!(a.store().has(&big_ref), "fetched on first read"); |
|
| 1202 | + | assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big); |
|
| 1203 | + | assert!(a.lazy_remote(&big_ref).is_none(), "no longer registered"); |
|
| 1204 | + | write(&root.join("a"), "t.txt", b"t2\n"); |
|
| 1205 | + | a.commit("text edit", &[]).unwrap(); |
|
| 1206 | + | ||
| 1207 | + | // clone --max-blob records the filter; the checkout fetches lazily. |
|
| 1208 | + | let b = Repo::clone_with(&url, &root.join("b"), Some(1024)).unwrap(); |
|
| 1209 | + | assert_eq!(b.config.max_blob, Some(1024)); |
|
| 1210 | + | assert_eq!(fs::read(root.join("b/big.bin")).unwrap(), big); |
|
| 1211 | + | assert_eq!(parse_size("2M").unwrap(), 2 << 20); |
|
| 1212 | + | assert!(parse_size("lots").is_err()); |
|
| 1213 | + | } |
modified crates/oxy-server/src/lib.rs +13 −2
| … | 130 unchanged lines |
||
| 131 | 131 | async fn fetch(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 132 | 132 | blocking(move || { |
|
| 133 | 133 | let r = app.open(&repo)?; |
|
| 134 | - | let changes = wire::decode_hashes(&body)?; |
|
| 135 | - | Ok(ok(wire::encode_bundle(&r.bundle_for(&changes)?))) |
|
| 134 | + | let (changes, max_blob) = wire::decode_fetch(&body)?; |
|
| 135 | + | Ok(ok(wire::encode_bundle(&r.bundle_for_filtered(&changes, max_blob)?))) |
|
| 136 | 136 | }) |
|
| 137 | 137 | .await |
|
| 138 | 138 | } |
|
| 139 | 139 | ||
| 140 | + | async fn objects(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> { |
|
| 141 | + | blocking(move || { |
|
| 142 | + | let r = app.open(&repo)?; |
|
| 143 | + | let refs = wire::decode_hashes(&body)?; |
|
| 144 | + | let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() }; |
|
| 145 | + | Ok(ok(wire::encode_bundle(&bundle))) |
|
| 146 | + | }) |
|
| 147 | + | .await |
|
| 148 | + | } |
|
| 149 | + | ||
| 140 | 150 | async fn push(State(app): State<Shared>, Path(repo): Path<String>, headers: HeaderMap, body: Bytes) -> Result<Response, Failure> { |
|
| 141 | 151 | app.authorize_push(&headers)?; |
|
| 142 | 152 | blocking(move || { |
|
| … | 13 unchanged lines |
||
| 156 | 166 | .route("/{repo}/oxy/v1/refs", get(refs)) |
|
| 157 | 167 | .route("/{repo}/oxy/v1/negotiate", post(negotiate)) |
|
| 158 | 168 | .route("/{repo}/oxy/v1/fetch", post(fetch)) |
|
| 169 | + | .route("/{repo}/oxy/v1/objects", post(objects)) |
|
| 159 | 170 | .route("/{repo}/oxy/v1/push", post(push)) |
|
| 160 | 171 | .with_state(app) |
|
| 161 | 172 | } |
|
| … | 25 unchanged lines |
modified crates/oxy-server/tests/http.rs +18 −0
| … | 92 unchanged lines |
||
| 93 | 93 | } |
|
| 94 | 94 | ||
| 95 | 95 | #[test] |
|
| 96 | + | fn objects_endpoint_serves_lazy_blobs() { |
|
| 97 | + | hermetic(); |
|
| 98 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 99 | + | let root = dir.path(); |
|
| 100 | + | let served = root.join("served"); |
|
| 101 | + | let origin = Repo::init(&served.join("origin")).unwrap(); |
|
| 102 | + | let big: Vec<u8> = (0..300_000u32).map(|i| (i % 253) as u8).collect(); // has NUL bytes: opaque |
|
| 103 | + | write(&served.join("origin"), "big.bin", &big); |
|
| 104 | + | write(&served.join("origin"), "t.txt", b"t\n"); |
|
| 105 | + | origin.commit("big", &[]).unwrap(); |
|
| 106 | + | let base = start(&served, vec![]); |
|
| 107 | + | let url = format!("{base}/origin"); |
|
| 108 | + | let a = Repo::clone_with(&url, &root.join("a"), Some(1000)).unwrap(); |
|
| 109 | + | assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big, "fetched through the objects endpoint during checkout"); |
|
| 110 | + | assert!(!a.snapshot("status").unwrap().changed); |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | #[test] |
|
| 96 | 114 | fn push_requires_a_token_when_configured() { |
|
| 97 | 115 | hermetic(); |
|
| 98 | 116 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 55 unchanged lines |
modified docs/spec.md +8 −5
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6d | |
|
| 5 | + | | **Status** | Draft 0.6e | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 515 unchanged lines |
||
| 524 | 524 | ||
| 525 | 525 | ### 13.5 Lazy objects |
|
| 526 | 526 | ||
| 527 | - | A blob or chunk may be **absent** in a partial repository (§17). The object store records absent refs it has been told about with the remote that promised them, and fetches on first read. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data. |
|
| 527 | + | A blob or chunk may be **absent** in a partial repository (§17). After a fetch with a blob filter, the repository records every blob its new changes reference but did not receive, together with the remote that withheld it (`.oxy/lazy/<ref>`), and fetches it through `objects` on first read, dropping the record once stored. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data. |
|
| 528 | 528 | ||
| 529 | 529 | ## 14. Identity, signatures, and trust policy |
|
| 530 | 530 | ||
| … | 159 unchanged lines |
||
| 690 | 690 | | Signed policy | `[policy bytes, [Signature]]` | |
|
| 691 | 691 | | `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null}`; the attestation is present only when it describes the current state | |
|
| 692 | 692 | | `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order | |
|
| 693 | - | | `fetch` request | `{0: [hashes]}`; response a `Bundle` | |
|
| 693 | + | | `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size | |
|
| 694 | + | | `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks | |
|
| 694 | 695 | | `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}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update | |
|
| 695 | 696 | ||
| 696 | - | The HTTP content type is `application/oxy-cbor`. The `objects` and `checkpoint` endpoints of §16.1 are not yet specified in this form. |
|
| 697 | + | The HTTP content type is `application/oxy-cbor`. The `checkpoint` endpoint of §16.1 is not yet specified in this form. |
|
| 697 | 698 | ||
| 698 | 699 | ### 16.4 Push validation |
|
| 699 | 700 | ||
| … | 139 unchanged lines |
||
| 839 | 840 | ||
| 840 | 841 | --- |
|
| 841 | 842 | ||
| 842 | - | *Changelog.* Draft 0.6d (2026-09-27): §11 checkpoint format as implemented, with the loading rule. |
|
| 843 | + | *Changelog.* Draft 0.6e (2026-09-27): lazy blobs: the `fetch` filter, the `objects` message, and the absent-blob record (§13.5, §16.3). |
|
| 844 | + | ||
| 845 | + | Draft 0.6d (2026-09-27): §11 checkpoint format as implemented, with the loading rule. |
|
| 843 | 846 | ||
| 844 | 847 | Draft 0.6c (2026-09-26): §23 import defines the base as the union of parent states, records author and commit trailers, skips submodules, and states the closure-versus-snapshot difference. |
|
| 845 | 848 | ||
| … | 21 unchanged lines |
modified src/cli/mod.rs +9 −3
| … | 25 unchanged lines |
||
| 26 | 26 | /// Create a repository in PATH (default: current directory) |
|
| 27 | 27 | Init { path: Option<PathBuf> }, |
|
| 28 | 28 | /// Clone a repository from a local path or an http(s) URL |
|
| 29 | - | Clone { url: String, dest: Option<PathBuf> }, |
|
| 29 | + | Clone { |
|
| 30 | + | url: String, |
|
| 31 | + | dest: Option<PathBuf>, |
|
| 32 | + | /// Leave blobs larger than this (e.g. 1M) to be fetched on first read |
|
| 33 | + | #[arg(long)] |
|
| 34 | + | max_blob: Option<String>, |
|
| 35 | + | }, |
|
| 30 | 36 | /// Show the working copy: changed paths and conflicts |
|
| 31 | 37 | Status, |
|
| 32 | 38 | /// One-line summary for shell prompts; fast and records nothing |
|
| … | 104 unchanged lines |
||
| 137 | 143 | #[command(subcommand)] |
|
| 138 | 144 | cmd: PolicyCmd, |
|
| 139 | 145 | }, |
|
| 140 | - | /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, workspace.bookmark, remote.<name>.url|token|ca) |
|
| 146 | + | /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, workspace.bookmark, remote.<name>.url|token|ca) |
|
| 141 | 147 | Config { |
|
| 142 | 148 | key: String, |
|
| 143 | 149 | value: String, |
|
| … | 89 unchanged lines |
||
| 233 | 239 | Cmd::Gc { now } => workspace::gc(now), |
|
| 234 | 240 | Cmd::Checkpoint => workspace::checkpoint(), |
|
| 235 | 241 | Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(), |
|
| 236 | - | Cmd::Clone { url, dest } => sync::clone(&url, dest), |
|
| 242 | + | Cmd::Clone { url, dest, max_blob } => sync::clone(&url, dest, max_blob.as_deref()), |
|
| 237 | 243 | Cmd::Remote { cmd } => match cmd { |
|
| 238 | 244 | RemoteCmd::Add { name, url } => sync::remote_add(&name, &url), |
|
| 239 | 245 | RemoteCmd::Remove { name } => sync::remote_remove(&name), |
|
| … | 23 unchanged lines |
modified src/cli/sync.rs +3 −2
| … | 6 unchanged lines |
||
| 7 | 7 | use oxy_core::repo::Repo; |
|
| 8 | 8 | use std::path::PathBuf; |
|
| 9 | 9 | ||
| 10 | - | pub fn clone(url: &str, dest: Option<PathBuf>) -> Result<i32> { |
|
| 10 | + | pub fn clone(url: &str, dest: Option<PathBuf>, max_blob: Option<&str>) -> Result<i32> { |
|
| 11 | 11 | let dest = dest.unwrap_or_else(|| { |
|
| 12 | 12 | let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo"); |
|
| 13 | 13 | PathBuf::from(base) |
|
| 14 | 14 | }); |
|
| 15 | + | let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?; |
|
| 15 | 16 | let sp = ui::spinner(&format!("Cloning {url}…")); |
|
| 16 | - | let repo = Repo::clone(url, &dest)?; |
|
| 17 | + | let repo = Repo::clone_with(url, &dest, max_blob)?; |
|
| 17 | 18 | let heads = repo.bookmark_heads()?; |
|
| 18 | 19 | sp.done(&format!("{} into {} ({} on {}: {})", ui::ok("Cloned"), ui::path(repo.root().display()), url, ui::id(&repo.config.bookmark), plural(heads.len(), "head"))); |
|
| 19 | 20 | Ok(0) |
|
| … | 146 unchanged lines |