5d034b0d09
Phase 2 — the client side of "upload only what changed", closing the delta-sync plan. WASM (wasm/oxicloud-hash): DeltaChunker adds incremental FastCDC with the server's exact crate and parameters (64K/256K/1M) next to the BLAKE3 hasher. The incremental split is provably identical to a single pass: every chunk except the last ends on a content/max-size condition whose decision window was fully buffered, so only the tail is provisional and re-examined as slices arrive. A mirror test — the client twin of the server's stream≡slice test — chunks 4 MiB of xorshift noise with adversarial slice sizes (7 B … 8 MiB) and requires boundary-for-boundary equality with one FastCDC pass. Vendored artifacts rebuilt (55 KB wasm). Worker (static/js/workers/deltaWorker.js): the full protocol off the main thread with OVERLAPPED stages — 8 MiB file slices feed the chunker while earlier batches (256 hashes) negotiate and their missing chunks upload through a 2-deep PUT pool (≤8 MiB framed bodies, bytes re-sliced from the File at send time, never hoarded). Commit handles 409 still_missing by uploading exactly the named hashes and retrying. Orchestrator (features/files/deltaUpload.js): threshold (8 MiB), worker lifecycle + size-scaled timeout, progress relay to the upload bell, conclusive-outcome mapping (201/200, 507 quota, 409 name conflict) and silent fallback to the byte upload for everything else. Wired into uploadFiles and uploadFolderEntries, which now surface one batch summary of the bytes dedup saved. This subsumes the whole-file instant-upload module — a fully-known file negotiates to nothing missing and the commit short-circuits on possession — so instantUpload.js and hashWorker.js are removed (the /api/dedup/check and /api/files/by-hash endpoints remain for API clients). Verified end-to-end against PostgreSQL 16 — the cross-boundary proof the whole design hangs on, in both directions: a 24 MB file byte- uploaded (server-side CDC) then edited and delta-negotiated with WASM-computed chunks reported missing 1/74 (boundaries bit-identical), synced with 344 KB on the wire vs 24 MB (98.6% saved) and downloaded byte-identical; inversely, a file created via delta then byte-uploaded as identical content produced a server-side manifest DEDUP HIT with the same content_hash. Insertion at the head of the file (the adversarial CDC case) still negotiated missing 1/74. Chunk+hash throughput ≈275 MB/s in V8 with SIMD128. https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS
145 lines
5.8 KiB
Markdown
145 lines
5.8 KiB
Markdown
# Delta-Upload Protocol
|
||
|
||
Upload only what changed. The server's dedup store already splits every
|
||
file into content-defined chunks (FastCDC, 64 KB – 1 MiB, avg 256 KB,
|
||
BLAKE3-addressed) and shares unchanged chunks between file versions —
|
||
but a classic upload still transfers every byte just for the server to
|
||
discard the known ones. This protocol moves the "which chunks are new?"
|
||
question to the client, so unchanged bytes never cross the wire.
|
||
|
||
Editing a few bytes of a 500 MB file re-uploads ~1 MiB instead of
|
||
500 MB.
|
||
|
||
## Who can use it
|
||
|
||
Any authenticated API client. The OxiCloud web frontend uses it
|
||
automatically for files ≥ 8 MiB (`features/files/deltaUpload.js` +
|
||
`workers/deltaWorker.js`, chunking with the vendored WASM build of the
|
||
server's own FastCDC+BLAKE3 crates, falling back to a plain byte upload
|
||
on any failure). Generic WebDAV/NextCloud clients cannot (their
|
||
protocols have no delta concept) — they keep uploading full bytes, and
|
||
the server keeps deduplicating those on write.
|
||
|
||
Chunk boundaries are the **client's choice**: matching the server's
|
||
FastCDC parameters (64 KB / 256 KB / 1 MiB, as the bundled WASM module
|
||
does) maximizes cross-version sharing — including against versions that
|
||
entered through plain byte uploads — but any split with chunks of
|
||
1 byte … 1 MiB is valid; correctness is guaranteed by server-side
|
||
verification, not by the chunking scheme.
|
||
|
||
## The three steps
|
||
|
||
### 1. `POST /api/files/delta/negotiate`
|
||
|
||
```json
|
||
{ "chunks": [ { "h": "<blake3-hex>", "s": 262144 }, … ] }
|
||
```
|
||
|
||
Response — the distinct chunk hashes the caller must upload, in
|
||
first-occurrence order:
|
||
|
||
```json
|
||
{ "missing": [ "<blake3-hex>", … ] }
|
||
```
|
||
|
||
The answer is **user-scoped**: a chunk counts as available only when one
|
||
of the *caller's own* (non-trashed) files already references it. The
|
||
endpoint is purely advisory — the commit re-checks entitlement
|
||
atomically, so a stale or spoofed answer can never leak content.
|
||
|
||
### 2. `PUT /api/files/delta/chunks`
|
||
|
||
Body: `application/octet-stream`, a sequence of frames
|
||
|
||
```
|
||
[u32 length, big-endian][length bytes] …repeated…
|
||
```
|
||
|
||
- one frame per chunk, each 1 byte … 1 MiB (the CDC maximum),
|
||
- whole request capped by `OXICLOUD_CHUNK_MAX_BYTES` (default 100 MB) —
|
||
split larger deltas across requests.
|
||
|
||
The server **recomputes BLAKE3 of every frame itself** (a declared hash
|
||
is never trusted for content addressing) and registers the chunks as
|
||
unreferenced orphans (`ref_count = 0`). Response:
|
||
|
||
```json
|
||
{ "received": [ { "h": "<server-computed>", "s": 262144 }, … ] }
|
||
```
|
||
|
||
Compare against your own hashes to catch corruption before committing.
|
||
Abandoned uploads need no cleanup call: the periodic GC sweeps
|
||
zero-reference chunks.
|
||
|
||
### 3. `POST /api/files/delta/commit`
|
||
|
||
```json
|
||
{
|
||
"file_hash": "<blake3-hex of the whole file>",
|
||
"chunks": [ { "h": "…", "s": 262144 }, … ], // full sequence, in order
|
||
"name": "video.mp4", "folder_id": "<uuid>" // create mode
|
||
// — or —
|
||
"file_id": "<uuid>" // update (replace content)
|
||
}
|
||
```
|
||
|
||
Server-side, in order:
|
||
|
||
1. **AuthZ** — `Create` on the folder (create mode) or `Update` on the
|
||
file (update mode); quota on the logical size.
|
||
2. **Pin** — one atomic `UPDATE … RETURNING` takes a reference on every
|
||
distinct chunk the caller is *entitled* to: chunks reachable through
|
||
the caller's own files, or unreferenced orphans (the just-uploaded
|
||
state). Anything else → `409 { "still_missing": […] }`: upload
|
||
exactly those and retry the same commit.
|
||
3. **Verify** — the pinned sequence is re-read and the whole-file BLAKE3
|
||
recomputed. A mismatch releases the pins and returns 400 (and an
|
||
audit event): the declared `file_hash` is never trusted, because a
|
||
forged manifest would poison future whole-file dedup hits for *other
|
||
users* uploading the genuine content.
|
||
4. **Attach** — the manifest is inserted with the same accounting as the
|
||
streaming byte path (a concurrent identical commit resolves via
|
||
`ON CONFLICT`: the loser's references are released and it becomes a
|
||
dedup hit).
|
||
5. **Row** — the file is created (`201`, body = FileDto) or its content
|
||
swapped (`200`).
|
||
|
||
If the caller already owns the exact `file_hash`, the commit
|
||
short-circuits to a pure reference bump — chunks aren't even looked at
|
||
(same as `POST /api/files/by-hash`).
|
||
|
||
## Security model
|
||
|
||
- **No content oracle.** Possession is proven per chunk: without bytes
|
||
you can only claim what your own files already reference. Probing
|
||
someone else's chunk hashes yields `still_missing`, indistinguishable
|
||
from the hash never existing.
|
||
- **No manifest poisoning.** `file_hash` and every chunk hash are
|
||
recomputed server-side before becoming addressable.
|
||
- **Bounded resources.** Per-frame cap 1 MiB, per-request cap
|
||
`OXICLOUD_CHUNK_MAX_BYTES`, whole-file cap `OXICLOUD_MAX_UPLOAD_SIZE`,
|
||
per-caller rate limit (240 delta requests/min), quota enforced at
|
||
commit. Orphan chunks are GC-swept.
|
||
- **Audit.** Rejections emit `delta_upload.rejected` with stable
|
||
`reason` keys: `rate_limited`, `chunk_verification_failed`,
|
||
`file_hash_mismatch`. AuthZ denials surface as the engine's standard
|
||
`authz.denied`.
|
||
|
||
## Error summary
|
||
|
||
| Status | Meaning | Client action |
|
||
|---|---|---|
|
||
| 400 | malformed framing/hashes/sizes, or `file_hash` mismatch | fix and retry from step 1 |
|
||
| 404 | folder/file not found or not accessible | — |
|
||
| 409 | `{"still_missing": […]}` | PUT those chunks, retry the commit |
|
||
| 429 | rate limited | back off |
|
||
| 507 | quota exceeded | — |
|
||
|
||
## Cost notes
|
||
|
||
- `negotiate` is one indexed query (GIN over manifest chunk arrays).
|
||
- `commit` performs one sequential server-side read of the full logical
|
||
file for verification — cheap on local backends, a full object read on
|
||
S3/Azure. Still strictly cheaper than receiving the bytes, and the
|
||
client's bandwidth saving is unaffected.
|