appview: bound upload buffer memory, reap abandoned uploads, and pin the flush boundary

Each in-flight blob upload buffers up to 16MB, Docker pushes five layers
at once per client, and nothing bounded the total. Writers also lived in
the package-level map forever: a client that died mid-push left its
writer, its buffer, and any hold-side S3 multipart session behind with
no expiry.

A process-wide budget (golang.org/x/sync semaphore, default 512MB,
server.upload_buffer_budget_mb) now caps memory held in upload buffers.
A writer charges its buffer's projected backing capacity before growing,
so a config blob costs kilobytes and a full writer costs exactly one
buffer, and releases once, on Commit, Cancel, or reap. A write that
needs budget waits on the request's context with a five minute cap,
outside the writer's lock so Cancel and the sweeper cannot queue behind
it; that wait is backpressure on the client. The budget is clamped to
at least one buffer so a single upload can never deadlock.

A sweeper started with the other appview workers reaps writers idle
past server.upload_idle_timeout (default 1h), aborting the hold-side
multipart on a detached context and releasing the budget. It measures
inactivity, not age, so a slow push is never reaped, and it skips a
writer whose lock is held so it cannot race a live part upload.

Write also gains a fix the budget made visible. It appended a whole
chunk and checked afterwards, so the last chunk before a flush could
land a few bytes past 16MB, which did not fit the backing array;
bytes.Buffer doubled it to 32MB and Reset kept that for the rest of the
upload. Only chunk sizes that tile 16MB exactly avoided it, and the
network read loop promises no such thing. Every large layer could hold
32MB while the budget charged 16. Write now fills to exactly the
threshold, flushes, and continues with the remainder, so capacity is
pinned at 16MB for any chunk size, every part is exactly one buffer,
and a single oversized Write streams through as parts instead of
buffering whole. The test streams 24KB chunks across the boundary and
fails against the old code with cap 33554432.

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 15:03:03 -05:00
co-authored by Claude Fable 5.1
parent f4343d7956
commit 47a107058d
11 changed files with 1110 additions and 22 deletions
+20 -1
View File
@@ -137,7 +137,7 @@ jetstream.backfill_enabled → ATCR_JETSTREAM_BACKFILL_ENABLED
| Section | Purpose | Notes |
|---------|---------|-------|
| `server` | Listen address, public URL, managed holds, branding | Only `managed_holds` is required |
| `server` | Listen address, public URL, managed holds, branding, blob upload limits | Only `managed_holds` is required |
| `ui` | Database path, theme, libSQL sync | All have defaults; auto-creates DB on first run |
| `auth` | JWT signing key/cert paths | Auto-generated on first run |
| `jetstream` | Real-time ATProto event streaming, backfill sync | Runs automatically; backfill enabled by default |
@@ -145,6 +145,25 @@ jetstream.backfill_enabled → ATCR_JETSTREAM_BACKFILL_ENABLED
| `log_shipper` | Remote log shipping (Victoria, OpenSearch, Loki) | Disabled by default |
| `legal` | Terms/privacy page customization | Optional |
### Blob upload memory
Each in-flight blob upload buffers up to 16MB in the AppView process, and Docker
pushes several layers at once per client, so concurrent pushes are bounded by two
`server` settings:
| Field | Default | Purpose |
|-------|---------|---------|
| `upload_buffer_budget_mb` | `512` | Process-wide ceiling on memory held in upload buffers. A push that would exceed it blocks until another upload finishes, which is backpressure on the Docker client rather than an error. Raised to 16MB (one buffer) if configured lower, since a smaller budget could never satisfy a single upload. |
| `upload_idle_timeout` | `1h` | How long an upload may go without a write before it is treated as abandoned. |
A background sweeper runs every 5 minutes on every instance (it is deliberately
not leased: the uploads it tracks are per-process). Anything idle past
`upload_idle_timeout` is cancelled: its buffer and budget are released, its
hold-side S3 multipart upload is aborted, and the client gets
`BLOB_UPLOAD_UNKNOWN` if it ever comes back, which makes Docker restart the
layer. Inactivity is the signal, not age, so a slow push that is still making
progress is never reaped.
### Auto-generated files
On first run (and each boot), AppView auto-generates these under `/var/lib/atcr/`: