feat(storage): add an S3-compatible object store - #94
Conversation
There was a problem hiding this comment.
Solid first slice — layout mirrors the local store correctly (dedupe via content key, isolation via marker), streaming upload/download avoids buffering large objects, and the stub-bucket tests cover the right shapes (marker vs content, cross-repo isolation, ranges, listing). No blocking issues; a couple of nits to consider before the seam-extraction slice wires this in:
Nit: content_key/marker_key (server/src/storage/s3.rs:52-63) slice oid[0..2]/oid[2..4] without validating length first. exists() guards with LocalStore::validate_oid before calling into these, but store() and size_of() don't — a malformed/short oid reaching either will panic (byte-index-out-of-bounds) instead of returning Err. Worth validating at the top of store/size_of too, same as exists, before routes start calling this.
Nit: head() (server/src/storage/s3.rs:82) signs the presigned URL via GetObject and then issues an HTTP HEAD against it. SigV4 presigned auth includes the HTTP method in the signature; this happens to work against the in-process stub because it doesn't check signatures at all. Worth confirming this round-trips against a real S3/MinIO endpoint (mentioned as a follow-up in the PR description) before relying on it.
Nit: usage_of (server/src/storage/s3.rs:180) does one HEAD per object after the listing — fine for the sizes involved here, but worth keeping in mind if repositories grow large before this gets cached.
SonarQube — aucune nouvelle issueComparaison entre le projet bac à sable de cette PR et la branche par défaut : SonarQube Community n'analyse pas les PR, ce delta est calculé côté CI. Détail |
|
Second slice: the seam, so this is reachable rather than dead code. Marking it ready.
The refactor that made it possible is worth pointing at: The seam test found a bug the S3 tests had missed.
|
There was a problem hiding this comment.
Follow-up review on the new commit (c4115b3) that wires the S3 seam in: Storage config, Store enum dispatching Local/Bucket, staging refactor (stage()/agrees() split out of write()), and the four maintenance ops correctly answering Unsupported/501 on a bucket instead of a fake empty success. The refactor preserves local-store semantics (fresh-tracking, budget, link-or-move all still gated the same way), and the new tests cover the upload/read round-trip, staging cleanup, and the 501 behavior.
No blocking issues. One new nit inline on backend.rs about usage() silently zeroing the /metrics gauges for bucket storage, which is the one place this PR reports empty success instead of saying so like it does everywhere else.
The three nits from the previous review (oid-length validation in s3.rs store/size_of, the HEAD-via-GetObject-signature question, per-object HEAD cost in usage_of) are still open and unchanged — not repeating them here, they still stand for the seam-extraction cleanup.
…reporting a bucket as empty
|
All three taken, and two of them were worth more than the nit label. Signing a HEAD as a GET. The unvalidated object id. Unreachable today — every caller validates first — but the fanout slices the first four characters, so a short id is an index out of bounds. A panic where the client deserves a 422, one refactor away. Guarded at all four entry points, with a test. That test earned its place immediately: the guard on The zeroed gauges. Right, and it contradicted the principle the rest of the PR argues for. Registering them conditionally would be the fuller fix — an absent series beats a stale one — but that reaches into the metrics registry for a mode nobody runs yet, so it can wait for the slice that makes buckets a real deployment. |
There was a problem hiding this comment.
Follow-up on ef939d6: this commit cleans up all three outstanding nits from the previous reviews.
- oid-length validation added to
S3Store::store/size_of/read(they now callLocalStore::validate_oidbefore slicing, matchingexists), with a test asserting""/"ab"/"abc"are refused rather than panicking. head()now signs aHeadObjectaction instead of aGetObjectaction for the HTTPHEADrequest, so SigV4 covers the right method against a real S3/MinIO endpoint.usage()→capacity() -> Option<(u64, u64)>: bucket storage now returnsNoneinstead of(0, 0), soscrape()leaves the/metricsgauges untouched rather than pinning them to a misleading zero. Documented in the README and logged at startup.
No new issues in the diff. No blocking findings.
First slice of #10, opened as a draft on purpose: nothing constructs this store yet, so it is not mergeable as it stands. What it does is settle the design and prove it against a wire, which is the part that would have been expensive to get wrong later.
Answering the question the issue leaves open first: proxy by default, pre-signed as an option for downloads. The server stays in the byte path so the counters, the ranges, the size limit, the quota and the compression keep working; an operator who would rather spend the bucket's bandwidth than their own can hand out a pre-signed URL instead.
presigned_downloadis here for that, unused until the second slice.The layout is the local one
The bytes live once under
.content/<xx>/<yy>/<oid>, and a repository that holds them owns an empty marker at<org>/<repo>/<xx>/<yy>/<oid>. That marker is the object store's answer to a hard link, and it carries both properties the local store gets from the filesystem:Uploads stream from the staging file
The obvious shape — read the object, PUT it — puts a three-gigabyte asset in memory. The upload has already been streamed to a staging file, hashed, and checked against everything the server enforces, so that file is what goes up, streamed from disk. The local disk becomes a write buffer rather than the store, and the flat-memory property the whole storage layer is built on survives.
Downloads are ranged GETs against the bucket, so resuming a transfer asks for the bytes it wants rather than the whole object.
Tests
Five, against an in-process stub bucket — PUT, HEAD, ranged GET and a prefix listing, the same shape as the stub forge the authentication tests use. A real MinIO belongs in CI, not in the path of every
cargo test.An object goes up once and comes back whole; a second repository adds a marker and not the bytes; a repository that never pushed an object does not hold it; a range asks the bucket for that range; and what a repository holds is counted from its markers.
Getting the stub honest took three corrections, each of which would have been a wrong assumption about real S3: the listing needs the
ListBucketResultnamespace, entries need anETag, and path-style addressing puts the bucket in the path so the stub had to strip it to keep a real keyspace.What the next slice has to do
The seam. Routes call
state.store— aLocalStore— in about ten places, and four of those operations (retain,dedupe,compress,audit) have no S3 equivalent yet. Extracting that seam, wiringLFSX_STORAGE=s3through configuration, and deciding what those four do on a bucket is the work that makes this reachable, and it deserves its own review rather than being bolted onto this one.