OXYGENHUB
# 7a3cc5149a14 oxygen / docs / stores.md
15.7 KiB · Markdown · last changed A repository kept on a store: oxy init --store 1 month ago renderedsource blame history raw
1
# Oxygen Anywhere: stores
2
3
Status: built 2026-10-03/04 (oxygen#63): the `Store` trait, directory
4
and in-memory stores, the bucket layout and the store remote, the S3,
5
GCS and Azure stores, the CLI (`remote add` settings, `remote check`,
6
`gc --remote`), and the user guide `docs/strata.md`. This document is
7
the design and the record of what was decided; the guide is for users.
8
9
## 1. What and why
10
11
Today an Oxygen repository lives in `.oxy/` on a disk, and a remote is
12
either another directory or an OxygenHub. This feature makes storage
13
pluggable. Any **store** that can keep write-once objects and update a
14
small reference record by compare-and-swap can hold a complete
15
repository, with no server in between.
16
17
The first stores are **Amazon S3** and **Google Cloud Storage**, built
18
side by side so the code stays portable. Azure Blob and a Git repository
19
used as a plain store follow on the same interface.
20
21
Why Oxygen can do this safely when Git cannot:
22
23
- **Objects never change.** Changes, states, blobs, attestations and
24
  receipts are named by their BLAKE3 hash and written once.
25
- **Only references are mutable.** Bookmarks, tags and the operation
26
  head are small, and object stores now offer compare-and-swap on a
27
  single object.
28
- **Every read is verified.** Objects are re-hashed on read and changes
29
  are signed, so a bucket is untrusted storage. Tampering is detected,
30
  and nobody can forge history, not even the bucket's owner.
31
32
Performance is "good enough" for one engineer today. This work is about
33
where a repository can live, not about speed.
34
35
## 2. What users see
36
37
Storage must be transparent: a bucket behaves like any other remote, with
38
no new concepts to learn.
39
40
```sh
41
oxy remote add backup s3://my-bucket/repos/oxygen
42
oxy push backup
43
oxy clone gs://team-bucket/metalgear
44
oxy pull
45
```
46
47
- **URLs:** `s3://BUCKET/PREFIX` and `gs://BUCKET/PREFIX`. The prefix is
48
  optional, so one bucket can hold many repositories.
49
- **Credentials come from the standard places**, with no oxy-specific
50
  configuration:
51
  - **AWS:** environment variables, `~/.aws` profiles (`AWS_PROFILE`),
52
    SSO, and instance or container roles;
53
  - **Google Cloud:** Application Default Credentials (`gcloud auth
54
    application-default login`), service account files, and workload
55
    identity.
56
  - **Overrides:** optional per-remote settings in the repository's
57
    config (`profile`, `region`, `endpoint`), for example
58
    `oxy remote add backup s3://b/p --profile work`.
59
- **Errors are written for people.** Each one says what to do next, for
60
  example: "no AWS credentials found: run `aws sso login` or set
61
  AWS_PROFILE", or "the bucket exists but this account cannot write to
62
  it (s3:PutObject denied on my-bucket/repos/oxygen)".
63
- **`oxy init --remote s3://…`** creates an empty repository in the
64
  bucket, and `oxy remote check NAME` reports access, latency and
65
  what is stored there.
66
- **Progress, colour and glyphs** follow the CLI style of every other
67
  command.
68
- **Several writers are safe.** Two people pushing to the same bucket at
69
  once is handled the way the operation log handles two processes today
70
  (§5).
71
72
## 3. Architecture
73
74
- **The `Store` trait** (`oxy_core::strata`, built 2026-10-03). It has
75
  two halves:
76
  - **objects**: `has`, `get`, `get_range`, `put_new` (create only),
77
    `list` by prefix, and `delete`;
78
  - **references**: `read_ref() -> (bytes, version)` and
79
    `write_ref(bytes, expected_version) -> Ok | Conflict`. A version is
80
    opaque and compared for equality only. Writing identical bytes may
81
    keep the version, as S3's content-based ETag does; compare-and-swap
82
    guards against losing a *different* record.
83
  - Keys are plain relative paths (`[A-Za-z0-9._-]` segments, no `.` or
84
    `..`), the same rule on every store, so a key can never escape a
85
    directory store.
86
  - Implementations: `LocalStore` (a directory: create-only through a
87
    hard link, references under a lock file beside them, so processes on
88
    one machine can share it), `MemoryStore` (tests), then S3 and GCS in
89
    a crate of their own.
90
  - `strata::conformance(store, prefix)` exercises any store under a
91
    probe prefix and leaves it empty: every implementation runs it in its
92
    tests, and `oxy remote check` runs it against a remote.
93
  - Since 2026-10-04 (oxygen#68, B1) the repository's own object store
94
    is behind the trait too: `.oxy/objects` is a `LocalStore` with the
95
    same keys a bucket would hold (`loose/`, `pack-*`, `nocompress/`,
96
    `sig/`, `seal/`, `receipt/`), and the pack code reads a packed
97
    object with one ranged read. A directory store is as fast as the
98
    files were: the same stats, reads and syncs, with a batch of loose
99
    objects synced together. What must be on this disk, a pack being
100
    streamed out and the derived `metas` cache, lives in a scratch
101
    directory, which for a directory store is the store itself.
102
- **The `object_store` crate** (Apache Arrow) for S3 and GCS: one API
103
  for both, mature credential chains, multipart uploads, and conditional
104
  puts. Azure is in the same crate, so it comes nearly free later.
105
- **Push and pull talk to the bucket directly** through the same
106
  negotiation the hub protocol uses: what does the remote have, and what
107
  is missing? The client sends packs, never loose objects.
108
- **A local cache** of fetched objects, which is always correct because
109
  objects never change.
110
- **The working repository stays local for now.** A bucket is a remote.
111
  Running a repository *from* a bucket is plan B (oxygen#68): a `Repo`
112
  opened over a bucket store with a local cache in front, so the hub
113
  holds no copy of its own and the data lives where its owner chose.
114
  B1 (the store over the trait) is done; B2 is the caching store and
115
  the references by compare-and-swap.
116
- **The operation head on a store** (B2, 2026-10-05): a repository whose
117
  objects are on any store but this disk's directory keeps its
118
  operation head in that store's `ops/head` reference record. Recording
119
  an operation reads the record and its version, and moves it only if
120
  the version is unchanged. When another process moved it first, the
121
  two operations are merged by the same rules as on disk, and the write
122
  is tried again (eight times at most). A repository on this disk keeps
123
  `.oxy/ops/head` under its lock file, as before, so older oxy builds
124
  share it safely.
125
- **The caching store** (B2, 2026-10-05): `strata::CachingStore` puts a
126
  directory store in front of any other. Reads go through it: a missing
127
  object is fetched from the remote and kept. Writes go to the remote
128
  first and then the cache, so the remote always holds everything and
129
  the cache can be deleted at any time. The cache has a size limit; past
130
  it the objects used least recently are removed, down to nine tenths of
131
  the limit. An object larger than an eighth of the limit is not kept:
132
  ranges of it are read from the remote each time. References are never
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`):
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
153
## 4. Layout in the bucket (as built, 2026-10-03)
154
155
```
156
PREFIX/
157
  oxygen.toml                      format version and repository id, written once by the first push
158
  bundles/<seq>-<hash>.bundle      one per push: changes, signatures, seals, objects, policies
159
                                   (the wire protocol's bundle encoding), written once, named by content
160
  refs                             every bookmark with its heads and attestation, every tag, the policy
161
                                   and its history (the protocol's refs response), moved by compare-and-swap
162
```
163
164
- **Pushing** uploads one bundle, then updates `refs` from the version
165
  it read. A bundle named in `refs` is complete, because the bundle is
166
  written before `refs` moves.
167
- **One `refs` record** so that a push moving several bookmarks is
168
  atomic. Per-reference records would scale to more writers but lose
169
  atomic pushes; one engineer and small teams do not need them.
170
- **Bundles are whole-file transfers.** Each push is one bundle, so a
171
  pull downloads only the bundles it has not seen. Packs with indexes
172
  for range reads, as first planned, are an optimisation for later
173
  (`oxy gc` against a remote merges bundles, and could write a pack).
174
- **The client does what a hub does.** A store cannot run code, so each
175
  client keeps a **mirror**, a bare repository of its own under
176
  `.oxy/remotes/<name>/strata/`, brought level with the store before
177
  every use: the record read, the bundles it has not seen imported (each
178
  object re-hashed), the view set from the record. A push runs the hub's
179
  own checks (`receive_push`: policy, attestations, fast-forward) on the
180
  mirror, uploads the bundle, then moves `refs`. A fetch verifies each
181
  bookmark's attestation under the policy, as for any remote. The
182
  mirror costs a second copy of the objects on disk; sharing them with
183
  the repository's own store is a later optimisation.
184
- **The record moved meanwhile:** the mirror is put back to what the
185
  store says, brought level, and the push's updates are applied over
186
  what landed (up to four times). Two pushes to different bookmarks
187
  both get through; one to the same bookmark is told it is stale, as a
188
  hub says: pull, then push again.
189
- **`oxy gc --remote NAME`** folds every bundle into one holding what
190
  the record reaches, and records the superseded ones in a `gc`
191
  record with the time. They are removed only once seven days have
192
  passed (`--now` ignores that), so a reader that listed them before
193
  the fold can still fetch them; a reader that finds one gone skips it,
194
  since the merged bundle holds the same changes.
195
- **A directory is a store too:** `oxy remote add usb dir:///media/usb/repo`
196
  (`dir://`, since a plain path or `file://` is another repository).
197
  Tests run the whole flow through it with no network.
198
199
## 5. Concurrency: compare-and-swap on `refs`
200
201
| Provider | Create only | Replace only if unchanged |
202
|---|---|---|
203
| S3 | `PutObject` with `If-None-Match: *` | `PutObject` with `If-Match: <ETag>` |
204
| GCS | `ifGenerationMatch=0` | `ifGenerationMatch=<generation>` |
205
206
When a push finds `refs` has moved, it re-reads `refs` and merges by the
207
same rules as the operation log's merge:
208
- a bookmark moved by only one side takes that side's value;
209
- moved differently by both, it keeps both sides' heads, as several
210
  heads;
211
- a tag keeps its first value.
212
213
It then retries the write. Uploading packs needs no locking: two writers
214
uploading the same objects is harmless.
215
216
**As built (2026-10-04):** the `object_store` crate (0.13.1: 0.14 brought in
217
`aws-lc-rs`, a C build, and `crc-fast`, whose `cdylib` the macOS cross
218
build cannot link) carries both
219
writes as `PutMode::Create` and `PutMode::Update(UpdateVersion { e_tag,
220
version })`; S3 needs `S3ConditionalPut::ETagMatch` switched on, which
221
sends `If-None-Match: *` and `If-Match: <ETag>`; GCS uses the generation
222
(`ifGenerationMatch`); Azure the ETag. A store's `Version` carries both
223
the ETag and the generation, so the same code serves all three.
224
Checked against the real services on 2026-10-03/04: every conditional
225
write refuses as required on S3, GCS and Azure.
226
227
## 6. Safety
228
229
- **The bucket is untrusted.** Every object is re-hashed on read, as
230
  local reads are now. Signatures and repository policy are checked on
231
  fetch exactly as for a hub remote.
232
- **Bound to one repository.** `oxygen.toml` names the repository id. A
233
  push to a bucket holding a different repository is refused, and so is
234
  a clone that finds packs naming another id.
235
- **Size limits.** The existing 1 GiB object cap, plus limits on index
236
  and `refs` sizes, so a hostile bucket cannot drive huge allocations.
237
- **Garbage collection never deletes in place.** `oxy gc` on a remote
238
  writes the merged pack and moves `refs` first. Packs no longer named
239
  are deleted only after a grace period (default 7 days), so a slow
240
  concurrent reader is never left with a missing pack.
241
- **Least privilege.** The documented IAM and GCS policies need only
242
  Get, Put, List and Delete under the prefix. A read-only key gives a
243
  working clone and pull.
244
- **No secrets in the repository.** Credentials stay in the providers'
245
  own chains or the OS keychain, never in `.oxy/config`.
246
247
## 7. Testing
248
249
- **One conformance suite** that every store must pass, run against
250
  local, S3 and GCS. It covers object round trips, range reads,
251
  create-only refusals, compare-and-swap conflicts, concurrent pushers
252
  merging, an interrupted push leaving the remote valid, gc with a
253
  concurrent reader, and a repository-id mismatch being refused.
254
- **In CI** (Flow jobs have no network): the in-memory and directory
255
  stores run the conformance check and the whole push/clone/pull flow.
256
- **Real buckets** for the cloud stores, run by hand from a machine that
257
  holds restricted credentials: `crates/oxy-strata/tests/cloud.rs` reads
258
  `OXY_TEST_S3`, `OXY_TEST_GCS` and `OXY_TEST_AZURE` (each a store URL
259
  under a test prefix, with `OXY_TEST_S3_PROFILE`,
260
  `OXY_TEST_GCS_CREDENTIALS` and `OXY_TEST_AZURE_CREDENTIALS` naming the
261
  identity), runs the conformance check and a repository round trip under
262
  a prefix of its own, and deletes what it wrote. Unset, the tests say so
263
  and pass.
264
- **Emulators** were tried and dropped: MinIO's images are no longer
265
  published publicly, and `fake-gcs-server` rejects the client's XML
266
  uploads. The real services are the test.
267
268
## 8. Order of work (done)
269
270
1. ✔ **The `Store` trait in oxy-core**, with a directory store and an
271
   in-memory one; the working repository's own store left as it was,
272
   then (B1, 2026-10-04) put behind the trait with the same layout.
273
2. ✔ **The bucket layout and the store remote**, tested with no network.
274
3. ✔ **S3, GCS and Azure stores**, in `crates/oxy-strata`, verified
275
   against the real services.
276
4. ✔ **`oxy remote add`, `push`, `pull`, `clone`** for store URLs, with
277
   the settings on the URL, `remote check`, and friendly errors.
278
   (`init --remote` was dropped: `remote add` and `push` bind a store.)
279
5. ✔ **`oxy gc --remote`**, with the grace period, and merge-and-retry
280
   for concurrent pushes.
281
6. ✔ **Documentation:** `docs/strata.md`, the README section, and this
282
   record.
283
284
Later, on the same trait:
285
- Azure Blob storage;
286
- a Git repository used as a plain store, so `oxy push` works to
287
  GitHub or GitLab;
288
- the hub attaching a repository from a customer's bucket ("bring your
289
  own storage", see oxygenhub `docs/scaling.md`);
290
- couriers: signed, encrypted bundles shared by email, Slack, Matrix
291
  and the like (a separate design);
292
- a virtual working copy mounted with FUSE (from OxygenFlow's
293
  `flow-casfs`).
294
295
## 9. Open questions
296
297
- Should `oxy clone s3://…` set the bucket as `origin`, or prompt for a
298
  name? Proposed: `origin`, as for any URL.
299
- Does a bucket remote keep its own operation log, or only `refs`?
300
  Proposed: only `refs` at first. The local operation log already
301
  records what each push did.
302
- Should packs be encrypted on the client, so the provider only ever
303
  stores ciphertext, beyond the provider's own encryption? Proposed:
304
  later, as an option. It does not change the layout.