Skip to content

fix: stop copy clamp under-reserve for multi-GB manifest copies#110

Merged
ServerSideHannes merged 10 commits into
mainfrom
fix/copy-clamp-under-reserve
Jul 6, 2026
Merged

fix: stop copy clamp under-reserve for multi-GB manifest copies#110
ServerSideHannes merged 10 commits into
mainfrom
fix/copy-clamp-under-reserve

Conversation

@ServerSideHannes

Copy link
Copy Markdown
Owner

Summary

  • Fix prod OOM after fix: stop memory governor over-count and clamp starvation #109: UploadPartCopy of 4.7GB Scylla .sm_ manifests used upload-style routine-peak clamp (59MB reservation) while each copy held ~175MB ciphertext at copy.py:420
  • Add copy_governor_clamped_reserve() and reserve_copy_memory() so large copies monopolize the budget slot (min(honest, budget)) instead of sharing at 59MB each
  • Three concurrent manifest copies on one pod now serialize at 192MB governor budget (was: 3×59MB reserved, ~790MB RSS, OOMKilled)

Root cause

PR #109's streaming_governor_clamped_reserve applied to all try_acquire() calls. Copies with honest peak ~500MB were clamped to routine upload peak ~59MB. Prod logs: governed_active_mb=178, rss_mb=790, MEMORY_DEBUG_TOP #1: copy.py:420 → 522MB (3 allocations).

Test plan

  • uv run pytest tests/unit/ — 499 passed
  • New tests/unit/test_copy_clamp_and_concurrency.py: manifest clamp, 3× exclusive acquire, upload+copy serialization, tracemalloc at 4.7GB
  • Deploy 2026.7.6 to staging; Scylla backup — confirm no OOMKilled pods during .sm_ manifest copy phase
  • Confirm MEMORY_CLAMPED_TO_BUDGET for copies shows reserved_mb=192 (not 59) and max 1 large copy per pod at governor limit

Ops follow-ups (separate)

  • Disable S3PROXY_MEMORY_DEBUG in ArgoCD
  • Cap KEDA max replicas while validating

Made with Cursor

ServerSideHannes and others added 10 commits July 6, 2026 12:46
Upload-style routine-peak clamp (59MB) applied to UploadPartCopy
reservations let three concurrent 4.7GB Scylla .sm_ manifest copies
each hold ~175MB ciphertext while the governor tracked ~178MB total,
OOMKilling pods at ~790MB RSS.

- Add copy_governor_clamped_reserve: min(honest, budget), not routine peak
- reserve_copy_memory / try_acquire(copy=True) for server-side copies
- Regression tests for prod manifest size, exclusive budget, mixed workload

Co-authored-by: Cursor <cursoragent@cursor.com>
- Tighten copy_pipeline_peak with measured part//7 transport slack at large parts
- Add tests/integration/test_copy_memory_governor.py (subprocess e2e: 3× copy
  serialize, mixed Scylla uploads + copy, RSS bound)
- Add unit tests: old upload-clamp regression, mixed workload, CopyObject path
- Strict tracemalloc bounds at 512MB–4.7GB without slack fudge factor

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
RepeatingBody needs tell/seek for botocore request checksums. Assert
SlowDown on concurrent copies rather than succeeded < 3 (sequential
success after backpressure is valid).

Co-authored-by: Cursor <cursoragent@cursor.com>
Split e2e tests by workload (memory/stress, core, multipart, copy+range,
misc) so slow 512MB copy-governor tests don't block the full integration
suite. Each shard gets its own MinIO/redis stack and pytest-xdist workers.

Co-authored-by: Cursor <cursoragent@cursor.com>
The single memory shard bundled test_memory_usage (2GB multipart),
test_memory_leak (20×256MB hammer), and copy governor (3×512MB
uploads) — wall clock was sum of all three (~10+ min).

- Split into memory_usage, memory_leak, memory_copy (7 parallel jobs)
- Copy e2e: 256MB shared source (module fixture), one upload not three
- 256MB still triggers exclusive 48MB governor slot (peak ~49.6MB)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
UploadPartCopy decrypts+reencrypts the full object; 256MB copies took
~3min each serialized. Use 64MB/32MB governor (peak still 48MB), drop
redundant RSS e2e (covered by unit tracemalloc), run shard with -n0.

Co-authored-by: Cursor <cursoragent@cursor.com>
32MB governor left no room after the request gate's MIN_RESERVATION on top
of a budget-monopolizing copy clamp; use 96MB and 5MB upload parts so one
copy plus concurrent uploads can succeed while a second copy still gets SlowDown.

Co-authored-by: Cursor <cursoragent@cursor.com>
BACKPRESSURE_TIMEOUT=2 let excess copies wait for a slot and all three
succeeded serially. Use 0 so the third copy gets SlowDown; assert at most
two succeed under the 96MB budget.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ServerSideHannes ServerSideHannes merged commit 148479a into main Jul 6, 2026
10 checks passed
ServerSideHannes added a commit that referenced this pull request Jul 7, 2026
A server-side COPY clamps its reservation to the whole budget so it runs
exclusively (guards the multi-copy OOM #110 fixed). The admission gate is
active_bytes + to_reserve <= limit, so a whole-budget request can only be
admitted when active_bytes == 0. The limiter had no fairness: every release
woke all waiters and a small request re-grabbed memory before the copy could,
so active_bytes never drained to 0 under steady traffic. The copy backpressured
the whole timeout (prod: MEMORY_BACKPRESSURE requested_mb == limit_mb) and 503'd,
retried, starved again -- effectively never admitted while the pod had traffic.

Add writer preference: track pending whole-budget (exclusive) waiters and hold
back new non-exclusive admissions while one waits, so active_bytes drains and
the copy gets its exclusive slot (bounded by in-flight request lifetimes).
Copies still reserve the whole budget and run one-at-a-time -- the existing
copy-concurrency/OOM regression tests are unchanged and still pass.

Regression test: a pending exclusive copy must not be starved by a stream of
small requests; the small ones queue behind it. Verified it fails against the
old no-fairness limiter with the exact prod signature (requested_mb==limit_mb).
ServerSideHannes added a commit that referenced this pull request Jul 7, 2026
A server-side COPY clamps its reservation to the whole budget so it runs
exclusively (guards the multi-copy OOM #110 fixed). The admission gate is
active_bytes + to_reserve <= limit, so a whole-budget request can only be
admitted when active_bytes == 0. The limiter had no fairness: every release
woke all waiters and a small request re-grabbed memory before the copy could,
so active_bytes never drained to 0 under steady traffic. The copy backpressured
the whole timeout (prod: MEMORY_BACKPRESSURE requested_mb == limit_mb) and 503'd,
retried, starved again -- effectively never admitted while the pod had traffic.

Add writer preference: track pending whole-budget (exclusive) waiters and hold
back new non-exclusive admissions while one waits, so active_bytes drains and
the copy gets its exclusive slot (bounded by in-flight request lifetimes).
Copies still reserve the whole budget and run one-at-a-time -- the existing
copy-concurrency/OOM regression tests are unchanged and still pass.

Regression test: a pending exclusive copy must not be starved by a stream of
small requests; the small ones queue behind it. Verified it fails against the
old no-fairness limiter with the exact prod signature (requested_mb==limit_mb).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant