hold: report blob size on read presigns so the appview can skip its S3 HEAD

distribution calls Stat before every blob GET and HEAD. The appview's
Stat asked the hold for a presigned HEAD URL and then HEADed S3 with it
purely to read Content-Length for the descriptor: two round trips to
learn one number.

The hold's getBlob response for OCI digests on GET and HEAD now carries
"size". It comes from the records index when a layer record exists (a
SQLite lookup on a new digest index, no network) and from a HeadObject
otherwise, which is where config blobs land. If storage says the object
does not exist the hold answers 404 instead of signing a URL that can
only fail. The PUT and ATProto CID paths are untouched.

The appview builds the descriptor from the reported size and makes no
S3 request. When the field is absent it HEADs the presigned URL as
before, so a new appview works against a hold that has not been
upgraded, and an old appview ignores the extra field. A hold 404 maps
to ErrBlobUnknown.

Tests prove the index answered by leaving the mock bucket empty and
counting zero HeadObject calls, prove the fallback with exactly one, and
count requests reaching the fake S3 origin on the appview side rather
than trusting the returned size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
This commit is contained in:
Evan Jarrett
2026-09-09 09:31:16 -05:00
co-authored by Claude Fable 5.1
parent 61a934debb
commit 034ea5988b
10 changed files with 729 additions and 38 deletions
+33
View File
@@ -33,6 +33,39 @@ This document lists all XRPC endpoints implemented in the Hold service (`pkg/hol
|----------|--------|-------------|
| `/xrpc/com.atproto.sync.getBlob` | GET/HEAD | Get blob (routes OCI vs ATProto) |
#### getBlob response shape
The endpoint routes on the `cid` parameter and answers differently per branch.
**ATProto blob** (`cid` is a CID): 307 redirect to a presigned URL.
**OCI blob** (`cid` starts with `sha256:`): JSON.
```json
{
"url": "https://s3.example.com/...?X-Amz-Signature=...",
"size": 27129344
}
```
| Field | Type | Notes |
|---|---|---|
| `url` | string | Presigned URL for the requested `method` (GET, HEAD or PUT). Always present on a 200. |
| `size` | int64 | Blob size in bytes. Present on GET and HEAD only, and omitted when the size could not be resolved. |
`size` lets the AppView build an OCI descriptor without a second round trip: `ProxyBlobStore.Stat` used to fetch a presigned HEAD URL here and then HEAD it against S3 purely to read `Content-Length`. It is resolved from the hold's records index (any layer with an `io.atcr.hold.layer` record) and falls back to a `HeadObject` (image config blobs, and layers whose manifest notification has not landed yet).
The field is additive. A client that reads only `url` behaves exactly as before, and an AppView that gets no `size` falls back to HEADing the presigned URL. `size` is never sent for a PUT presign: the object does not exist yet.
On GET and HEAD, a blob that is not in storage is answered with **404** and a JSON error body instead of a presigned URL:
```json
{
"error": "BlobUnknown",
"message": "blob not found"
}
```
### Owner/Crew Admin Required
| Endpoint | Method | Description |