mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-23 18:54:16 +00:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WTdBxLFU5TpwmqVdVsN1wq
198 lines
8.9 KiB
Markdown
198 lines
8.9 KiB
Markdown
# Performance Backlog
|
|
|
|
Findings from a September 2026 read of the appview and hold request paths,
|
|
focused on the handshakes between the two services and on image uploads. The
|
|
items that were worth doing immediately are listed first for context. The rest
|
|
are recorded here so they are not lost. None of them is urgent; each is a known
|
|
cost with a known fix.
|
|
|
|
All counts below come from reading the code, not from measurements, except the
|
|
size distribution in the appendix, which was queried from the production
|
|
appview database.
|
|
|
|
## Done
|
|
|
|
| Commit | Change |
|
|
|---|---|
|
|
| `9228579` | One shared HTTP transport for the blob proxy instead of a new transport per registry request. |
|
|
| `61a934d` | Hold-side auth looks a crew member up by record key instead of walking the whole crew collection. |
|
|
| `034ea59` | Hold reports blob size on read presigns; appview Stat no longer HEADs S3 for it. |
|
|
| `f4343d7` | Blobs under 16MB go up as one presigned PUT; every upload is hashed and verified against its digest. |
|
|
| `47a1070` | Process-wide budget on upload buffer memory; sweep for abandoned uploads; flush boundary pinned at exactly 16MB. |
|
|
| `2a94f92` | Per-request memo of blob presigns; a blob GET is one hold call, not three (Stat, ServeBlob and the notification listener's Stat shared it). |
|
|
|
|
In flight as of 2026-09-09: the registry reads the sailor profile from the
|
|
local users row instead of the owner's PDS on every request, and part uploads
|
|
are pipelined so one part is in flight while the next fills.
|
|
|
|
## Remaining
|
|
|
|
### Stat from the appview's own layers table
|
|
|
|
**Problem.** For a layer of an already indexed manifest, the appview's `layers`
|
|
table (`pkg/appview/db/schema.sql`) knows the digest and size, with an index on
|
|
digest, yet Stat still asks the hold.
|
|
|
|
**Fix.** Answer Stat from the local row when the digest is known for that
|
|
owner. Fall through to the hold on a miss: a freshly uploaded blob, or a blob
|
|
whose manifest lives on a different hold, will not be in the table, and a miss
|
|
must not become blob-unknown.
|
|
|
|
**Impact.** Zero network for Stat on known layers, including Docker's
|
|
pre-upload existence checks for layers that were pushed before.
|
|
|
|
**Caveat.** Sizes in the table come from manifest descriptors the pusher
|
|
declared, not from the stored object. The hold's records index has the same
|
|
property since `034ea59`.
|
|
|
|
### Batch presigned part URLs from the hold
|
|
|
|
**Problem.** Each multipart part costs a hold round trip to fetch its presigned
|
|
URL. Part numbers are predictable and the URLs are valid for 15 minutes.
|
|
|
|
**Where.** `getPartUploadInfo` in `proxy_blob_store.go`;
|
|
`HandleGetPartUploadURL` in `pkg/hold/oci/xrpc.go`.
|
|
|
|
**Fix.** Let the hold return URLs for the next N parts from one call, either
|
|
from initiateUpload or from a batched part URL endpoint. The writer consumes
|
|
them in order and asks again when it runs out.
|
|
|
|
**Impact.** One hold round trip per 16MB removed. Does not change the shape of
|
|
the transfer; the pipeline does that.
|
|
|
|
### Hold complete handler: redundant HeadObject, and the copy
|
|
|
|
**Problem.** `CompleteMultipartUploadWithManager` in
|
|
`pkg/hold/oci/multipart.go` does CompleteMultipartUpload, then HeadObject on
|
|
the temp key, then CopyObject to the final key, then DeleteObject. The
|
|
HeadObject buys nothing: CopyObject fails on a missing source. The copy scales
|
|
with object size on most S3-compatible backends, and plain CopyObject fails
|
|
above 5GB.
|
|
|
|
**Fix.** Drop the HeadObject. For the copy, either use multipart copy
|
|
(UploadPartCopy) above the single-copy limit, or find a way to know the final
|
|
key before the first part lands. The digest is only known at the final PUT, so
|
|
the second option needs Docker's monolithic upload path or a client hint and is
|
|
not a small change.
|
|
|
|
**Impact.** One S3 call per multipart upload now; the copy cost only matters
|
|
for very large layers, which are about 1% of distinct layers but 11% of bytes.
|
|
|
|
### One signed repo commit per layer record on notify
|
|
|
|
**Problem.** `HandleNotifyManifest` in `pkg/hold/oci/xrpc.go` loops
|
|
`CreateLayerRecord` once per layer, each a full repo commit under the user lock
|
|
with its own signature and firehose event. Stats and daily stats add two or
|
|
three more commits. A 10-layer push is about 13 sequential commits.
|
|
|
|
**Fix.** `BatchCreateLayerRecords` in `pkg/hold/pds/layer.go` already exists
|
|
and is used only by GC. Use it here. Consider folding the stats updates into
|
|
the same batch write.
|
|
|
|
**Impact.** Two or three commits per push instead of one per layer. Also fewer
|
|
firehose events for relays to ingest.
|
|
|
|
### Layer-record dedup scans every layer record on the hold
|
|
|
|
**Problem.** `ListLayerRecordsForManifest` in `pkg/hold/pds/layer.go` pages the
|
|
entire layer collection through the records index, fetches and CBOR-decodes
|
|
each record from the CAR store, and filters by manifest URI in memory. It runs
|
|
once per push notify. Cost is proportional to the total number of layer records
|
|
on the hold, not to the manifest.
|
|
|
|
**Fix.** The records index (`pkg/hold/pds/records.go`) already has a `did`
|
|
column with an index, so `ListRecordsByDID` would narrow the scan to the
|
|
pushing user's records. Adding a `manifest` column with an index makes it a
|
|
single indexed query.
|
|
|
|
**Impact.** Proportional to one manifest instead of the whole hold. Matters
|
|
most on the shared hold with many crew.
|
|
|
|
### Config blob fetched twice per push
|
|
|
|
**Problem.** On manifest PUT the appview fetches the config blob through the
|
|
hold to extract labels (`extractConfigLabels` in
|
|
`pkg/appview/storage/manifest_store.go`). The hold then fetches the same blob
|
|
from S3 in notify to create the image config record.
|
|
|
|
**Fix.** Include the config bytes, or just the parsed labels, in the notify
|
|
payload, or have the hold cache what it fetched. Small either way.
|
|
|
|
### Successor drain on every push
|
|
|
|
**Problem.** `MigrateManifestsForSuccessor` in
|
|
`pkg/appview/storage/drain.go` runs in the background on every push. It is
|
|
guarded per DID but still fetches the profile and queries the database before
|
|
discovering that no hold has a successor.
|
|
|
|
**Fix.** Check the local captain record cache for any successor first, which
|
|
is a cheap query, and skip the rest when there is none.
|
|
|
|
### Hold multipart sessions expire without an S3 abort
|
|
|
|
**Problem.** `MultipartManager.cleanupExpiredSessions` in
|
|
`pkg/hold/oci/multipart.go` drops its in-memory session after 24 hours of
|
|
inactivity but does not abort the S3 multipart. Parts from a session the
|
|
appview never reaped, for example across an appview restart, stay in the
|
|
bucket.
|
|
|
|
**Fix.** Abort in S3 when the session expires, or add a bucket lifecycle rule
|
|
for incomplete multipart uploads. The appview's sweep (`47a1070`) covers the
|
|
common case; this is the backstop.
|
|
|
|
### Pipeline depth
|
|
|
|
**Problem.** Once part uploads are pipelined with one part in flight, a second
|
|
in-flight part could overlap more on high-latency links.
|
|
|
|
**Fix.** Generalize the two-buffer swap to N buffers with ordered completion.
|
|
Budget cost is N buffers per large upload.
|
|
|
|
**Impact.** Uncertain; measure with one in flight first.
|
|
|
|
## Not performance, found along the way
|
|
|
|
### Crew edit handler deletes the member it just updated
|
|
|
|
`pkg/hold/admin/handlers_crew.go`, around the role and permissions change:
|
|
the handler calls `AddCrewMember`, which upserts at the deterministic record
|
|
key for the member DID, then `RemoveCrewMember` with the record key from the
|
|
URL. For any member created since the deterministic key scheme, those keys are
|
|
the same, so the delete removes the record that was just written and the
|
|
member disappears. The comment above it about a transient duplicate describes
|
|
the earlier TID-keyed world. Fix: skip the delete when the URL key equals the
|
|
deterministic key.
|
|
|
|
### Single Write larger than the budget
|
|
|
|
`Put` in `proxy_blob_store.go` hands the writer a whole blob in one slice.
|
|
Since `47a1070` the writer splits it into threshold-sized parts, so this no
|
|
longer buffers the whole slice; noted here only because the earlier behavior
|
|
was referenced in commit messages.
|
|
|
|
## Appendix: production size distribution
|
|
|
|
Queried read-only from the appview database on 2026-09-09. Sizes come from
|
|
manifest layer descriptors.
|
|
|
|
Distinct layer digests: 9,286. Manifests: 4,203, of which 3,357 have layers of
|
|
their own (the rest are manifest lists and indexes).
|
|
|
|
| Threshold | Distinct layers under it | Bytes under it | Manifests with every layer under it |
|
|
|---|---|---|---|
|
|
| 5 MB | 72% | 4% | |
|
|
| 10 MB | 79% | 10% | 43% |
|
|
| 16 MB | 86% | 21% | 49% |
|
|
| 32 MB | 93% | 38% | 70% |
|
|
| 64 MB | 99% | 64% | 96% |
|
|
|
|
Config blobs average about 2 KB; the largest is under 30 KB.
|
|
|
|
Reading: blob count drives the fixed per-blob overhead, and 86% of blobs skip
|
|
multipart at 16MB. Bytes drive transfer time, and 79% of bytes are in layers
|
|
over 16MB, so large-layer improvements (pipelining, part URL batching, the
|
|
copy on complete) are what move push time for big images. The largest layer of
|
|
an image clusters at 16 to 64 MB, which is where base image layers land, so
|
|
raising the threshold to 32MB would make 70% of images all-direct-PUT at the
|
|
cost of 32MB of buffer per in-flight large upload.
|