Skip to content

Let workers drain and restart cleanly before leaked memory takes down the host - #163

Open
mihow wants to merge 5 commits into
mainfrom
feat/worker-drain-and-recycle
Open

Let workers drain and restart cleanly before leaked memory takes down the host#163
mihow wants to merge 5 commits into
mainfrom
feat/worker-drain-and-recycle

Conversation

@mihow

@mihow mihow commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

The ML worker's host memory grows with the work it has done and is not released while the process lives. Two rounds of measurement on two production GPU hosts (2026-08-11):

  • After several days of jobs, idle worker processes held 6.0, 9.5, and 17.1 GB of resident memory, while a control worker from the same image on the same hosts that had processed very little sat at a 0.9 GB baseline.
  • After a clean restart of every worker, a process that had completed exactly one job held 13.1–15.1 GB while idle, within 90 minutes of starting — and the worker whose job was roughly 400 images retained about as much as the one whose job was roughly 3,200 images. That reads as per-job retention of roughly fixed size (models, pipeline objects, DataLoader machinery allocated per job and never freed), rather than the time-based "GB per hour" ramp we previously assumed, and not proportional to image count either.

Caveat, stated plainly: the second round is two samples, and both processes had loaded models for the first time in that window — a long-lived worker is supposed to hold resident model weights, so some of that 13–15 GB may be legitimate. The distinguishing test is whether RSS climbs further after a second and third job, which has not been run yet. This PR builds the instrument for it (see change 3 below). These hosts run without swap, so if per-job retention is real, the terminal state on a 68 GB host is a hard kernel OOM after roughly four to five jobs per process — an outage, not a slowdown.

This is a separate defect from the GPU out-of-memory failures addressed in PR #162. That one is a fixed peak working set colliding with a co-tenant process on a shared card (the same 426 MiB allocation failing 504 times at a stable 13.49 GiB allocated — no drift). This one is unbounded growth of ordinary host memory that scales with jobs processed; idle GPU memory retention measured at the same moment was only 124–722 MiB per process, and the DataLoader subprocesses suspected of the retention (#144 Part B, #145) never touch CUDA. Different memory, different processes, different mechanism, different fix; conflating them cost days of triage earlier.

This PR does not fix the leak — it bounds the damage, implementing the drain-and-exit proposal from #147. A worker can be told (or can decide on its own, via opt-in caps) to finish the batch in flight, post its results, and exit cleanly with status 0, so its process supervisor restarts a fresh process. Any leak — this one or a future one — becomes routine process churn instead of a host outage.

Why this change rather than the alternatives on the table: #144 Part A (LRU model eviction) manages GPU memory of idle models, not this host-RAM growth, and is gated on the leak audit; the root-cause work in #145 first needs instrumentation to find the retaining call sites; PR #148 improves DataLoader hygiene and by its own measurements removes the catastrophic per-job blow-up, but residual growth across jobs remains (its RSS figures do not return to baseline). And if retention really is per-job, recycling after N jobs caps it deterministically without the leak ever being located. Bounded process lifetime is the smallest change that does that, and it stays useful as defense in depth after the underlying bugs are fixed. It complements #148 rather than overlapping it — #148 explicitly lists worker recycling as out of scope.

List of Changes

  1. Operators can recycle a worker without losing in-flight work. Sending SIGUSR1 (for example supervisorctl signal USR1 <program>) makes the worker finish the batch it is processing, post those results, and exit with status 0 for the supervisor to restart. The only lever before this was SIGTERM, which kills mid-batch and forces queue redelivery of the in-flight work.
  2. A worker can bound its own memory retention, by job count or by size. Two new settings, both default-off: AMI_WORKER_MAX_JOBS drains the worker after that many jobs — the deterministic bound, since job count is what the measurements say predicts growth; AMI_WORKER_MAX_RSS_MB drains when resident memory sampled between jobs exceeds the cap — the backstop for whatever the job count does not predict. A job that raised mid-processing counts toward the job cap (it may retain memory like a completed one); job claims that yielded no work do not.
  3. Every job leaves a memory data point in the logs. The worker logs Resident memory after job N: X MiB between jobs, when the working set is at its idle baseline. This makes the open question above — does RSS keep climbing after jobs 2 and 3, or plateau after the first model load? — answerable from ordinary production logs, with no host instrumentation.
  4. Long jobs stop at batch boundaries, never mid-batch. _process_job accepts a should_stop callable checked at every batch boundary. On a stop, results for completed batches are still flushed, and the job's remaining tasks stay queued for the next worker (or the restarted one) to claim.
  5. Multi-GPU hosts drain as a group. Process supervisors deliver signals to the parent process only, so the parent forwards SIGUSR1 to each spawned per-GPU worker instance.
  6. Tests (trapdata/antenna/tests/test_worker_drain.py, 16 tests): the signal handler and drain state; both recycle caps in both directions (fire past their threshold, stay silent at the 0 default — a cap that fired while disabled would recycle every deployment that did not opt in); the per-job log line; the batch-boundary stop with and without a stop request; and the polling loop exiting after a signal.

What draining does and does not guarantee

  • Guaranteed: the batch in flight completes, its results are posted, and the process exits 0. No mid-batch kill, no half-posted batch.
  • Not guaranteed: batches the DataLoader has already prefetched but not yet processed are not processed; their tasks return through the queue's normal redelivery timeout, exactly as with any worker restart today.
  • The recycle caps are checked between jobs only, so a single very long job can still grow past the memory cap while it runs. If observation shows that matters in practice, a between-batches check is a small follow-up.

What still needs verification

Relationship to other open work

Test and lint results

  • New drain tests: 16/16 pass.
  • Full suite (uv run pytest --import-mode=importlib): 54 passed, 1 skipped, 1 failed — the failure is trapdata/api/tests/test_models.py::TestSourceImageSchema::test_url, which live-fetches an image from Wikimedia and received an HTTP 400 from the external server; pre-existing environmental flake, unrelated to this branch.
  • pre-commit (black, isort, flake8 + bugbear, autoflake): all hooks pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HcXFHJRXMrsHPX7xz9ZifF

Summary by CodeRabbit

  • New Features
    • Added graceful worker recycling through signal requests, job-count limits, and resident-memory limits.
    • Workers now finish in-progress work, flush results, and stop cleanly at batch boundaries.
    • Added configurable settings for job and memory limits; setting either to 0 disables that limit.
  • Documentation
    • Documented worker recycling configuration and supervisor restart requirements.

The worker's resident host memory grows with the amount of work done and
is not released while the process lives; on hosts without swap the end
state is a kernel OOM kill. Until the retention itself is found and fixed
(#144, #145), bound the damage by giving the worker a clean way to exit
and be restarted fresh by its process supervisor (#147):

- SIGUSR1 asks the worker to finish the batch in flight, post its
  results, and exit with status 0. Previously the only recycle lever was
  SIGTERM, which kills mid-batch and forces queue redelivery of the
  in-flight work.
- _process_job accepts a should_stop callable checked at every batch
  boundary, so a drain during a long job stops between batches: completed
  batches' results are posted and the job's remaining tasks stay queued
  for the next worker to claim.
- New AMI_WORKER_MAX_RSS_MB setting (default 0 = disabled): resident
  memory is sampled between jobs — when the working set is at its idle
  baseline, so a high reading indicates retained memory rather than a
  busy job — and a reading over the cap triggers the same drain path.
- On multi-GPU hosts the parent process forwards SIGUSR1 to the spawned
  per-GPU worker instances, since process supervisors signal only the
  parent.

Closes #147

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcXFHJRXMrsHPX7xz9ZifF
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mihow, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 48385464-d104-4ac4-bbf8-5dbeebf1160e

📥 Commits

Reviewing files that changed from the base of the PR and between 8c0f9b2 and 41a9d0e.

📒 Files selected for processing (4)
  • .env.example
  • trapdata/antenna/tests/test_worker_drain.py
  • trapdata/antenna/worker.py
  • trapdata/settings.py
📝 Walkthrough

Walkthrough

Workers can drain through SIGUSR1, a job-count limit, or an RSS limit. They stop at batch boundaries, flush completed results, leave remaining tasks queued, and exit cleanly. Multi-GPU parents propagate drain requests to child workers.

Changes

Worker recycling

Layer / File(s) Summary
Recycle configuration and drain contracts
trapdata/settings.py, trapdata/antenna/worker.py, .env.example
Added nonnegative worker_max_jobs and worker_max_rss_mb settings. Added drain-state handling, RSS checks, and recycling documentation.
Worker drain execution
trapdata/antenna/worker.py
Workers handle SIGUSR1, propagate parent drain events, stop polling and claiming jobs, stop _process_job at batch boundaries, flush completed results, and exit cleanly after configured limits.
Drain and recycle validation
trapdata/antenna/tests/test_worker_drain.py
Added tests for signal handling, parent propagation, RSS and job-count limits, settings validation, batch stopping, result flushing, and worker-loop termination.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Supervisor
  participant WorkerLoop
  participant ProcessJob
  participant ResultPoster
  Supervisor->>WorkerLoop: Send SIGUSR1
  WorkerLoop->>ProcessJob: Request stop at batch boundary
  ProcessJob->>ResultPoster: Flush completed results
  WorkerLoop->>Supervisor: Exit after drain
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes graceful worker draining, clean restart, and memory protection.
Linked Issues check ✅ Passed The changes implement SIGUSR1 draining, batch completion, result handling, clean exit, recycling caps, logging, and multi-GPU propagation required by issue #147.
Out of Scope Changes check ✅ Passed The changes remain within worker-side recycling and documentation scope; no unrelated deployment tooling, Celery changes, or underlying bug fixes were added.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/worker-drain-and-recycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
trapdata/antenna/worker.py (1)

61-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add types to the signal callback signatures.

Add parameter and return types to handle_signal and _forward_drain. These callbacks receive a signal number and a frame object.

As per coding guidelines, “Use type hints in function signatures to document expected types without requiring extensive documentation.”

Also applies to: 149-153

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@trapdata/antenna/worker.py` at line 61, Add type annotations to the signal
callback methods handle_signal and _forward_drain, annotating the signal number,
frame object, and return type according to the project’s typing conventions
while preserving their existing callback behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Around line 19-24: Update the worker recycling documentation around
AMI_WORKER_MAX_RSS_MB to require successful-exit restarts: specify supervisord
autorestart=true and Docker restart policy always or unless-stopped, while
retaining the existing systemd guidance.

In `@trapdata/antenna/worker.py`:
- Around line 137-153: Update the startup flow around mp.spawn and
_forward_drain to register a parent SIGUSR1 handler before spawning, retain
signals received before readiness, and forward them only after every child
reports its _DrainRequest.handle_signal is installed. Add parameter and return
type hints to both signal callbacks, and add a multi-GPU startup test covering
signals during initialization.

In `@trapdata/settings.py`:
- Line 48: Update the worker_max_rss_mb setting validation and _check_rss_cap
behavior to reject negative values, reserving only 0 to disable the RSS cap.
Preserve positive values as active limits and use the existing settings
validation mechanism.

---

Nitpick comments:
In `@trapdata/antenna/worker.py`:
- Line 61: Add type annotations to the signal callback methods handle_signal and
_forward_drain, annotating the signal number, frame object, and return type
according to the project’s typing conventions while preserving their existing
callback behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a4098012-540e-4571-b6f3-00f5a7d3d1c4

📥 Commits

Reviewing files that changed from the base of the PR and between a33746a and 0aefac8.

📒 Files selected for processing (4)
  • .env.example
  • trapdata/antenna/tests/test_worker_drain.py
  • trapdata/antenna/worker.py
  • trapdata/settings.py

Comment thread .env.example
Comment thread trapdata/antenna/worker.py Outdated
Comment thread trapdata/settings.py Outdated
… job

A fresh production measurement changed the leak's shape: a worker that had
completed exactly one job held 13-15 GB of resident memory while idle,
whether the job was ~400 images or ~3,200 — retention that looks per-job
and roughly fixed-size, not a per-hour ramp and not proportional to image
count. (Alternative not yet excluded: both samples were the process's
first job, so part of that is legitimately resident model weights.)

If retention is per-job, a job count predicts growth better than a memory
threshold, so:

- New AMI_WORKER_MAX_JOBS setting (default 0 = disabled): after that many
  jobs the worker drains and exits cleanly for its supervisor to restart —
  a deterministic bound that works before the retention's size or source
  is known. A job that raised mid-processing counts too, since it may
  retain memory like a completed one; claims that yielded no work do not.
- The worker logs "Resident memory after job N: X MiB" between jobs,
  where the working set is at its idle baseline. This is the instrument
  for the open question above: whether RSS keeps climbing after the
  second and third job, or plateaus after the first model load, is now
  answerable from ordinary worker logs.
- _check_rss_cap is folded into _after_job_check alongside the new
  trigger; the RSS cap behaves as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcXFHJRXMrsHPX7xz9ZifF
@mihow

mihow commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

This doesn't seem necessary right now. I recommend we focus on the leak. Also the tests are failing.

…e caps

Worker instances are started with the spawn method, so each begins life with
SIGUSR1 at its default action of terminating the process, and installs its own
handler only once the worker loop runs. Forwarding the signal from the parent
could land in that window and kill a child that was still starting up, and
torch responds by terminating its siblings. The parent was also still at
SIGUSR1's default action itself until after mp.spawn returned, so a drain
arriving while its children were already running killed the parent and orphaned
workers holding GPU memory.

The parent now owns SIGUSR1 on behalf of the whole group and publishes drains
through a shared spawn-context event that each instance reads at the safe
points it already checks. That removes both windows rather than narrowing them:
a drain requested before an instance is ready is simply observed on its first
check. Worker instances also install their drain handler before slower startup
work.

Recycle caps now reject negative values, which the threshold checks would
otherwise read as "disabled", quietly turning off a cap an operator meant to
set. Only 0 disables a cap.

.env.example now states that recycling requires a process manager configured to
restart the worker after a successful exit, since a drain exits with status 0.
Supervisord's autorestart=unexpected, systemd's Restart=on-failure and Docker's
on-failure policy all leave a cleanly exited worker stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcXFHJRXMrsHPX7xz9ZifF

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
trapdata/antenna/worker.py (1)

303-307: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

An idle worker can delay the drain exit by up to SLEEP_TIME_SECONDS.

time.sleep is restarted after a Python signal handler returns, so a SIGUSR1 that arrives during the idle sleep does not shorten it. The loop only re-checks drain.requested after the full sleep. Issue #147 requires a prompt exit when no batch is active.

Sleep in short increments and break when a drain is requested.

♻️ Proposed change
         if not any_jobs and not drain.requested:
             logger.info(
                 f"[GPU {gpu_id}] No jobs found, sleeping for {SLEEP_TIME_SECONDS} seconds"
             )
-            time.sleep(SLEEP_TIME_SECONDS)
+            slept = 0.0
+            while slept < SLEEP_TIME_SECONDS and not drain.requested:
+                time.sleep(min(1.0, SLEEP_TIME_SECONDS - slept))
+                slept += 1.0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@trapdata/antenna/worker.py` around lines 303 - 307, Update the idle-worker
sleep in the loop handling no jobs and no drain request to use short, repeated
intervals instead of one full SLEEP_TIME_SECONDS call; re-check drain.requested
between intervals and break immediately once a drain is requested, while
preserving the existing idle logging and total sleep duration when no drain
occurs.
🧹 Nitpick comments (2)
trapdata/antenna/worker.py (1)

223-225: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the return type annotation to _worker_loop.

The parameters are typed, but the return type is missing. The coding guidelines require type hints in function signatures.

♻️ Proposed change
 def _worker_loop(
     gpu_id: int, pipelines: list[str], drain_event: EventType | None = None
-):
+) -> None:

As per coding guidelines: "Use type hints in function signatures to document expected types without requiring extensive documentation".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@trapdata/antenna/worker.py` around lines 223 - 225, Add the appropriate
return type annotation to the _worker_loop function signature, preserving its
existing parameters and behavior.

Source: Coding guidelines

trapdata/antenna/tests/test_worker_drain.py (1)

34-36: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a return type to _spawn_event.

Declare the event return type. This makes the helper contract explicit for _DrainRequest tests.

As per coding guidelines, “Use type hints in function signatures to document expected types without requiring extensive documentation.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@trapdata/antenna/tests/test_worker_drain.py` around lines 34 - 36, Add an
explicit return type annotation to the _spawn_event function, using the
appropriate multiprocessing event type while preserving its existing
spawned-context Event construction.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@trapdata/settings.py`:
- Around line 45-48: Update the RSS limit descriptions in trapdata/settings.py
lines 45-48 and .env.example lines 21-23 to state that the worker exits when
resident memory reaches or exceeds the configured cap, matching the inclusive
check in _after_job_check.

---

Outside diff comments:
In `@trapdata/antenna/worker.py`:
- Around line 303-307: Update the idle-worker sleep in the loop handling no jobs
and no drain request to use short, repeated intervals instead of one full
SLEEP_TIME_SECONDS call; re-check drain.requested between intervals and break
immediately once a drain is requested, while preserving the existing idle
logging and total sleep duration when no drain occurs.

---

Nitpick comments:
In `@trapdata/antenna/tests/test_worker_drain.py`:
- Around line 34-36: Add an explicit return type annotation to the _spawn_event
function, using the appropriate multiprocessing event type while preserving its
existing spawned-context Event construction.

In `@trapdata/antenna/worker.py`:
- Around line 223-225: Add the appropriate return type annotation to the
_worker_loop function signature, preserving its existing parameters and
behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 820f0b63-873f-46a8-bd5c-105773aa29bf

📥 Commits

Reviewing files that changed from the base of the PR and between 0aefac8 and 8c0f9b2.

📒 Files selected for processing (4)
  • .env.example
  • trapdata/antenna/tests/test_worker_drain.py
  • trapdata/antenna/worker.py
  • trapdata/settings.py

Comment thread trapdata/settings.py
mihow and others added 2 commits August 11, 2026 22:23
The between-jobs check drains at `rss_mb >= max_rss_mb`, so a worker sitting
exactly at the cap recycles. The setting's description, the operator guidance
and the warning it logs all said "exceeds", which reads as strictly greater. A
test pins the boundary so the documented behaviour and the check cannot drift
apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcXFHJRXMrsHPX7xz9ZifF
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.

Add SIGUSR1 drain-and-exit handler to worker; enable safer auto-recycle

1 participant