OXYGENHUB

A repository kept on a store: oxy init --store d069701a4997

oxy init --store URL (storage.url, set at init) keeps the objects and
the operation head on dir://, S3, GCS or Azure behind a cache in
.oxy/cache of storage.cache bytes (2 GiB by default); .oxy/objects is
then scratch only. Repo::init_on, init_bare_on and init_new take the
store. Tested over dir:// (reopened with the cache dropped) and
measured against the real services: init 0.4-0.8 s, a commit 0.5-0.9 s,
a cold open 0.5-0.6 s. refs #68
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 01:13 UTC
Signature
✔ signed by Andy McCall
Hash
d069701a4997c24716b412a12b1e828a3c578aebc2ddc3276f16def9a3901bde
Depends on
454236d1108d 47ce8b8e2d4b 4e88e774777a 6d3eefbce2f0 75167672ebcd 8d80356371c5 8e1194860efa 8fd78eb52ad2 982864068a59 9f9e72b93e4e a70dce536476 b3c5a59a875a c07406bcc237 d42369abf180 dd21fd82687d e02bf0cd0533 fce44ae9ad29
Depended on by
771661aa50f4 8b3c9b2ba502 42ccf504aa50 80677b7a3513 7026bd7dc5b8
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.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
27 atoms, 0 tree operations, 9 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A repository kept on a store (#68).** `oxy init --store URL` keeps a repository's objects and operation log on `dir://`, S3, GCS or Azure, behind a local cache of `storage.cache` bytes (2 GiB by default) that can be deleted at any time. Checked against the real services: a commit takes 0.5 to 0.9 seconds, and reopening with no cache about half a second.
6 +
5 7
- **A caching store, and the operation head on a store (#68).** `strata::CachingStore` puts a local directory in front of any store: reads are kept, writes go to the remote first, the cache stays under a size limit by removing what was used least recently, and references always come from the remote. A repository kept on a store other than this disk's directory now holds its operation head there, moved by compare-and-swap with merge and retry.
6 8
7 9
- **A large merge's file no longer imports empty (#71).** When a merge rendered its files in batches, a file whose graph the import had already built came out empty, so the merge recorded the whole file as inserted again and conflicted with itself: VS Code's history failed verification from 2016 on. Built graphs are now copied. The 10 412-commit reproducer imports and verifies every commit.
…
108 unchanged lines
modified crates/oxy-core/src/repo/config.rs +22 −0
…
41 unchanged lines
42 42
    pub semantic_deps: bool,
43 43
    /// `revset.<name>`: a saved revset, used as `name()`.
44 44
    pub revsets: BTreeMap<String, String>,
45 +
    /// `storage.url`: the store the repository's objects and operation
46 +
    /// head are kept on (`dir://`, `s3://`, …) instead of `.oxy/objects`
47 +
    /// (oxygen#68).
48 +
    pub storage_url: Option<String>,
49 +
    /// `storage.cache`: the most the local cache in front of that store
50 +
    /// holds, in bytes.
51 +
    pub storage_cache: u64,
45 52
}
46 53
47 54
impl Config {
…
20 unchanged lines
68 75
            sparse_paths: Vec::new(),
69 76
            semantic_deps: false,
70 77
            revsets: BTreeMap::new(),
78 +
            storage_url: None,
79 +
            storage_cache: DEFAULT_STORAGE_CACHE,
71 80
        }
72 81
    }
73 82
…
65 unchanged lines
139 148
                    Some(parse_size(value)?)
140 149
                }
141 150
            }
151 +
            "storage.url" => {
152 +
                self.storage_url = (!value.is_empty()).then(|| value.to_string());
153 +
            }
154 +
            "storage.cache" => {
155 +
                self.storage_cache = if value.is_empty() {
156 +
                    DEFAULT_STORAGE_CACHE
157 +
                } else {
158 +
                    parse_size(value)?
159 +
                }
160 +
            }
142 161
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
143 162
            "filter.paths" => {
144 163
                self.sparse_paths = value
…
87 unchanged lines
232 251
}
233 252
234 253
/// A byte count with an optional `K`, `M`, or `G` suffix (binary units).
254 +
/// `storage.cache` when unset: 2 GiB.
255 +
pub const DEFAULT_STORAGE_CACHE: u64 = 2 << 30;
256 +
235 257
pub fn parse_size(v: &str) -> Result<u64> {
236 258
    let v = v.trim();
237 259
    let (num, mult) = match v.chars().last() {
…
47 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +66 −6
…
67 unchanged lines
68 68
/// processes keep moving it.
69 69
const HEAD_TRIES: usize = 8;
70 70
71 +
/// The object store of the repository directory `oxy`: `.oxy/objects`,
72 +
/// or the store `storage.url` names behind a cache of `storage.cache`
73 +
/// bytes in `.oxy/cache` (oxygen#68). `.oxy/objects` is then only
74 +
/// scratch space: packs being written, and derived caches.
75 +
fn open_objects(oxy: &Path, config: &Config) -> Result<Store> {
76 +
    let Some(url) = &config.storage_url else {
77 +
        return Ok(Store::open(oxy.join("objects"))?);
78 +
    };
79 +
    let options = crate::protocol::RemoteOptions {
80 +
        cache_dir: Some(oxy.join("cache")),
81 +
        ..Default::default()
82 +
    };
83 +
    let remote = crate::protocol::open_store(url, &options)
84 +
        .ok_or_else(|| {
85 +
            RepoError::Msg(format!(
86 +
                "storage.url {url}: this oxy cannot open that kind of store (dir://, or s3://, gs:// and az:// in builds with cloud stores)"
87 +
            ))
88 +
        })?
89 +
        .map_err(|e| RepoError::Msg(format!("open the repository's store {url}: {e}")))?;
90 +
    let cached = crate::strata::CachingStore::open(
91 +
        std::sync::Arc::from(remote),
92 +
        oxy.join("cache"),
93 +
        config.storage_cache,
94 +
    )
95 +
    .map_err(|e| RepoError::Msg(format!("open the cache for {url}: {e}")))?;
96 +
    Ok(Store::open_on(
97 +
        std::sync::Arc::new(cached),
98 +
        oxy.join("objects"),
99 +
    )?)
100 +
}
101 +
71 102
fn fail<T>(s: impl Into<String>) -> Result<T> {
72 103
    Err(RepoError::Msg(s.into()))
73 104
}
…
229 unchanged lines
303 334
    /// Initialize a repository with a given repository ID (clones share the
304 335
    /// ID of their origin, spec §14.4).
305 336
    pub fn init_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> {
337 +
        Self::init_on(root, id, None)
338 +
    }
339 +
340 +
    /// A new repository with a fresh id, bare or not, on `storage` or in
341 +
    /// `.oxy/objects` (`oxy init [--bare] [--store URL]`).
342 +
    pub fn init_new(root: &Path, bare: bool, storage: Option<&str>) -> Result<Repo> {
343 +
        let mut id = [0u8; 16];
344 +
        rand::rngs::OsRng.fill_bytes(&mut id);
345 +
        if bare {
346 +
            Self::init_bare_on(root, id, storage)
347 +
        } else {
348 +
            Self::init_on(root, id, storage)
349 +
        }
350 +
    }
351 +
352 +
    /// Initialize a repository whose objects and operation head are kept
353 +
    /// on the store at `storage` (`storage.url`), or in `.oxy/objects`.
354 +
    pub fn init_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> {
306 355
        fs::create_dir_all(root)?;
307 356
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
308 357
        let oxy = root.join(OXY_DIR);
…
3 unchanged lines
312 361
        fs::create_dir_all(oxy.join("ops"))?;
313 362
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
314 363
        write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?;
315 -
        let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
364 +
        let mut config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
365 +
        if let Some(url) = storage {
366 +
            config.set("storage.url", url)?;
367 +
        }
316 368
        config.save(&oxy.join("config"))?;
317 369
        let workspace = WorkspaceId::random();
318 370
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
319 -
        let store = Store::open(oxy.join("objects"))?;
371 +
        let store = open_objects(&oxy, &config)?;
320 372
        let repo = Repo {
321 373
            last_change: Default::default(),
322 374
            at_op: None,
…
37 unchanged lines
360 412
    }
361 413
362 414
    pub fn init_bare_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> {
415 +
        Self::init_bare_on(root, id, None)
416 +
    }
417 +
418 +
    /// [`Repo::init_on`] for a bare repository.
419 +
    pub fn init_bare_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> {
363 420
        fs::create_dir_all(root)?;
364 421
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
365 422
        let oxy = root.join(OXY_DIR);
…
4 unchanged lines
370 427
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
371 428
        write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?;
372 429
        write_atomic(&oxy.join(BARE_MARKER), b"")?;
373 -
        let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
430 +
        let mut config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
431 +
        if let Some(url) = storage {
432 +
            config.set("storage.url", url)?;
433 +
        }
374 434
        config.save(&oxy.join("config"))?;
375 -
        let store = Store::open(oxy.join("objects"))?;
435 +
        let store = open_objects(&oxy, &config)?;
376 436
        let repo = Repo {
377 437
            last_change: Default::default(),
378 438
            at_op: None,
…
71 unchanged lines
450 510
                    return fail(format!("unsupported repository format: {}", format.trim()));
451 511
                }
452 512
                let config = Config::load(Some(&default_user_config()), &shared.join("config"))?;
453 -
                let store = Store::open(shared.join("objects"))?;
513 +
                let store = open_objects(&shared, &config)?;
454 514
                let mut repo = Repo {
455 515
                    last_change: Default::default(),
456 516
                    at_op: None,
…
26 unchanged lines
483 543
                    WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
484 544
                        .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?
485 545
                };
486 -
                let store = Store::open(oxy.join("objects"))?;
546 +
                let store = open_objects(&oxy, &config)?;
487 547
                let mut repo = Repo {
488 548
                    last_change: Default::default(),
489 549
                    at_op: None,
…
1737 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +68 −0
…
7591 unchanged lines
7592 7592
    );
7593 7593
    crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None);
7594 7594
}
7595 +
7596 +
/// oxygen#68: a repository kept on a store (`storage.url`) holds its
7597 +
/// objects and operation head there, with `.oxy/cache` in front: it works
7598 +
/// as one on disk does, reopens, and survives losing its cache.
7599 +
#[test]
7600 +
fn a_repository_runs_on_a_store_with_a_cache_in_front() {
7601 +
    hermetic();
7602 +
    if std::env::var_os("OXY_STORE_BACKEND").is_some() {
7603 +
        return;
7604 +
    }
7605 +
    let dir = tempfile::tempdir().unwrap();
7606 +
    let (root, bucket) = (dir.path().join("work"), dir.path().join("bucket"));
7607 +
    let url = format!("dir://{}", bucket.display());
7608 +
    let repo = Repo::init_on(&root, [7; 16], Some(&url)).unwrap();
7609 +
    assert_eq!(repo.config.storage_url.as_deref(), Some(url.as_str()));
7610 +
    write(&root, "a.txt", b"one\n");
7611 +
    let h1 = repo.commit("First", &[]).unwrap();
7612 +
    write(&root, "a.txt", b"one\ntwo\n");
7613 +
    let h2 = repo.commit("Second", &[]).unwrap();
7614 +
    assert!(
7615 +
        !root.join(".oxy/ops/head").exists(),
7616 +
        "the head is in the store"
7617 +
    );
7618 +
    assert!(bucket.join("ops/head").is_file());
7619 +
    assert!(root.join(".oxy/cache").is_dir());
7620 +
    drop(repo);
7621 +
7622 +
    // Reopened, and again with no cache at all: everything comes from the store.
7623 +
    for drop_cache in [false, true] {
7624 +
        if drop_cache {
7625 +
            std::fs::remove_dir_all(root.join(".oxy/cache")).unwrap();
7626 +
        }
7627 +
        let repo = Repo::open(&root).unwrap();
7628 +
        let log: Vec<String> = repo
7629 +
            .op_log()
7630 +
            .unwrap()
7631 +
            .into_iter()
7632 +
            .map(|(_, op)| op.command)
7633 +
            .collect();
7634 +
        assert!(log.len() >= 3, "{log:?}");
7635 +
        assert_eq!(repo.store().get_change(&h1).unwrap().message, "First");
7636 +
        let heads = repo
7637 +
            .store()
7638 +
            .get_state(&repo.view().unwrap().bookmarks["main"])
7639 +
            .unwrap()
7640 +
            .heads;
7641 +
        assert_eq!(heads, vec![h2]);
7642 +
        assert_eq!(
7643 +
            repo.load_state(&heads).unwrap().materialize().files.len(),
7644 +
            1
7645 +
        );
7646 +
    }
7647 +
7648 +
    // A second repository cannot start on the same store.
7649 +
    let err = Repo::init_on(&dir.path().join("other"), [8; 16], Some(&url))
7650 +
        .err()
7651 +
        .unwrap()
7652 +
        .to_string();
7653 +
    assert!(
7654 +
        err.contains("already holds a repository's operation log"),
7655 +
        "{err}"
7656 +
    );
7657 +
    let err = Repo::init_on(&dir.path().join("third"), [9; 16], Some("nope://x"))
7658 +
        .err()
7659 +
        .unwrap()
7660 +
        .to_string();
7661 +
    assert!(err.contains("cannot open that kind of store"), "{err}");
7662 +
}
modified crates/oxy-strata/tests/cloud.rs +57 −0
…
141 unchanged lines
142 142
    }
143 143
}
144 144
145 +
/// oxygen#68: a small repository kept on each store (`storage.url`), with
146 +
/// the local cache in front: commits, a reopen with the cache dropped, and
147 +
/// the time each step took. Then every key the test wrote is removed.
148 +
#[test]
149 +
fn a_repository_runs_on_every_configured_store() {
150 +
    for (url, base_store) in each_store() {
151 +
        remove_stale(&base_store);
152 +
        let dir = tempfile::tempdir().unwrap();
153 +
        let root = dir.path();
154 +
        std::fs::create_dir_all(root.join("home")).unwrap();
155 +
        std::env::set_var("OXY_HOME", root.join("home"));
156 +
        let (base, query) = match url.split_once('?') {
157 +
            Some((b, q)) => (b.to_string(), format!("?{q}")),
158 +
            None => (url.clone(), String::new()),
159 +
        };
160 +
        let store_url = format!("{base}/repo-{}{query}", std::process::id());
161 +
        let plain = format!("{base}/repo-{}", std::process::id());
162 +
        let store = CloudStore::open(&store_url).unwrap();
163 +
        let t = std::time::Instant::now();
164 +
        let repo = Repo::init_new(&root.join("w"), false, Some(&store_url))
165 +
            .unwrap_or_else(|e| panic!("{plain}: init: {e}"));
166 +
        let init_ms = t.elapsed().as_millis();
167 +
        let t = std::time::Instant::now();
168 +
        let mut last = None;
169 +
        for i in 0..5 {
170 +
            write(
171 +
                root,
172 +
                "w/f.txt",
173 +
                format!("line {i}\n").repeat(i + 1).as_bytes(),
174 +
            );
175 +
            last = Some(repo.commit(&format!("commit {i}"), &[]).unwrap());
176 +
        }
177 +
        let commit_ms = t.elapsed().as_millis() / 5;
178 +
        drop(repo);
179 +
        std::fs::remove_dir_all(root.join("w/.oxy/cache")).unwrap();
180 +
        let t = std::time::Instant::now();
181 +
        let repo = Repo::open(&root.join("w")).unwrap();
182 +
        let heads = repo.bookmark_heads().unwrap();
183 +
        let state = repo.load_state(&heads).unwrap().materialize();
184 +
        let cold_ms = t.elapsed().as_millis();
185 +
        assert_eq!(heads, vec![last.unwrap()], "{plain}");
186 +
        assert_eq!(state.files.len(), 1);
187 +
        assert!(
188 +
            store.read_ref("ops/head").unwrap().is_some(),
189 +
            "{plain}: the head is in the store"
190 +
        );
191 +
        let keys = store.list("").unwrap();
192 +
        let total: u64 = keys.iter().map(|e| e.size).sum();
193 +
        eprintln!(
194 +
            "{plain}: init {init_ms} ms, commit {commit_ms} ms each, cold open and read {cold_ms} ms; {} keys, {total} bytes",
195 +
            keys.len()
196 +
        );
197 +
        remove_all(&store, "");
198 +
        assert!(store.list("").unwrap().is_empty(), "{plain}: cleanup");
199 +
    }
200 +
}
201 +
145 202
fn write(root: &Path, rel: &str, content: &[u8]) {
146 203
    let p = root.join(rel);
147 204
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
…
2 unchanged lines
modified docs/stores.md +18 −0
…
130 unchanged lines
131 131
  the limit. An object larger than an eighth of the limit is not kept:
132 132
  ranges of it are read from the remote each time. References are never
133 133
  cached, so compare-and-swap is always the remote's.
134 +
- **A repository on a store** (B2, 2026-10-05): `oxy init --store URL`
135 +
  (or `storage.url` in `.oxy/config`, set at init) keeps the objects and
136 +
  the operation head on that store, behind a cache in `.oxy/cache` of
137 +
  `storage.cache` bytes (2 GiB by default). `.oxy/objects` then holds
138 +
  only scratch: packs being written and derived caches. The cache can be
139 +
  deleted at any time. A second repository cannot start on a store that
140 +
  already holds one. Measured from the workstation against the real
141 +
  services with five small commits (`a_repository_runs_on_every_configured_store`):
134 142
143 +
  | Store | init | each commit | open with no cache, read the files |
144 +
  |---|---|---|---|
145 +
  | S3 | 441 ms | 703 ms | 481 ms |
146 +
  | GCS | 769 ms | 857 ms | 570 ms |
147 +
  | Azure | 664 ms | 511 ms | 639 ms |
148 +
149 +
  A commit writes its objects one request each and moves the head, so
150 +
  its cost is round trips, not bytes; batching a commit's objects into
151 +
  one pack is the next step for speed.
152 +
135 153
## 4. Layout in the bucket (as built, 2026-10-03)
136 154
137 155
```
…
140 unchanged lines
modified docs/strata.md +22 −0
…
119 unchanged lines
120 120
later (`--now` removes them at once), so a clone that was listing them at
121 121
the time still completes.
122 122
123 +
## A repository kept on a store
124 +
125 +
A store can also hold a repository itself rather than a copy of it.
126 +
Start one with `--store`:
127 +
128 +
```sh
129 +
oxy init myproject --store s3://my-bucket/repos/myproject?profile=work
130 +
```
131 +
132 +
- **Where things live:** the objects and the operation log are in the
133 +
  store. `.oxy/cache` keeps the objects this machine has used, up to
134 +
  `storage.cache` (2 GiB unless you set it, for example
135 +
  `oxy config storage.cache 10G`).
136 +
- **The cache:** you can delete `.oxy/cache` at any time. Objects come
137 +
  back from the store as they are needed.
138 +
- **One repository per prefix:** `oxy init --store` refuses a prefix
139 +
  that already holds a repository's operation log.
140 +
- **Speed:** each command that records something makes a few requests to
141 +
  the store, so a commit takes about half a second to a second.
142 +
- **Choose at init:** set `storage.url` only when the repository is
143 +
  created. Changing it later does not move the objects.
144 +
123 145
## What a store holds
124 146
125 147
```
…
37 unchanged lines
modified src/cli/mod.rs +6 −1
…
43 unchanged lines
44 44
        /// No working copy: an object store and references only, for hosting
45 45
        #[arg(long)]
46 46
        bare: bool,
47 +
        /// Keep the objects and the operation log on this store
48 +
        /// (dir://PATH, s3://BUCKET/PREFIX, gs://…, az://…) instead of
49 +
        /// in .oxy, with a local cache in front (storage.cache, 2 GiB)
50 +
        #[arg(long, value_name = "URL")]
51 +
        store: Option<String>,
47 52
    },
48 53
    /// Clone a repository from a local path or an http(s) URL
49 54
    Clone {
…
990 unchanged lines
1040 1045
            Ok(0)
1041 1046
        }
1042 1047
        Cmd::Man { dir } => man(dir),
1043 -
        Cmd::Init { path, bare } => workspace::init(path, bare),
1048 +
        Cmd::Init { path, bare, store } => workspace::init(path, bare, store.as_deref()),
1044 1049
        Cmd::Status { json } => workspace::status(json),
1045 1050
        Cmd::Prompt {
1046 1051
            format,
…
332 unchanged lines
modified src/cli/workspace.rs +15 −6
…
13 unchanged lines
14 14
use std::io::Write;
15 15
use std::path::PathBuf;
16 16
17 -
pub fn init(path: Option<PathBuf>, bare: bool) -> Result<i32> {
17 +
pub fn init(path: Option<PathBuf>, bare: bool, store: Option<&str>) -> Result<i32> {
18 18
    let path = path.unwrap_or(std::env::current_dir()?);
19 -
    let repo = if bare {
20 -
        Repo::init_bare(&path)?
21 -
    } else {
22 -
        Repo::init(&path)?
23 -
    };
19 +
    let repo = Repo::init_new(&path, bare, store)?;
24 20
    println!(
25 21
        "{} {}oxy repository in {}",
26 22
        ui::done("Initialized"),
…
4 unchanged lines
31 27
        "{}",
32 28
        ui::kv("bookmark", ui::bookmark(&repo.config.bookmark))
33 29
    );
30 +
    if let Some(url) = &repo.config.storage_url {
31 +
        println!(
32 +
            "{}",
33 +
            ui::kv(
34 +
                "store",
35 +
                format!(
36 +
                    "{url} (cache in {}, up to {} MiB)",
37 +
                    ui::path(repo.root().join(".oxy/cache").display()),
38 +
                    repo.config.storage_cache >> 20
39 +
                )
40 +
            )
41 +
        );
42 +
    }
34 43
    println!(
35 44
        "{}",
36 45
        ui::kv(
…
2523 unchanged lines