Background
Silent slow-failure modes have been observed in worker processes during sustained job ingestion. They are tracked in:
Each of these is being investigated separately, but they share a property: the worker process keeps running while no longer making progress, and the supervisor sees RUNNING. The result is wasted GPU cycles, unacked NATS tasks, and downstream redelivery noise — sometimes for hours before an operator notices.
A defense-in-depth measure is to recycle the worker process periodically, so any unknown leak/hang mode becomes noise rather than an incident. Recycling is not a substitute for fixing the underlying bugs, but it is a useful floor of correctness while those fixes land.
Scope of this issue
This issue covers the worker-side change required to make recycling safe and ergonomic:
- A drain-and-exit signal handler so the worker can be told "finish the current batch, ack the message, then exit cleanly" — without losing in-flight work or leaving NATS messages unacked.
The operator-side pieces (cron schedules, health-driven scripts, container-level health checks) live in private deployment tooling and are out of scope for this issue.
A separate change in the platform repo will add --max-tasks-per-child to the Celery result-callback workers; that is a different fork-pool runtime and is not addressed here.
Proposal: SIGUSR1 = drain-and-exit
Standard Unix signal flow. Operator (or the supervisor's restart command) sends SIGUSR1, the worker sets a flag, finishes the in-flight batch, acks NATS, and exits cleanly with status 0. Whatever process supervisor is in use (supervisord, systemd, docker restart: always) respawns it with a fresh DataLoader / FD pool / RSS.
# trapdata/cli/start_worker.py (or equivalent worker main)
import signal
DRAIN_REQUESTED = False
def request_drain(signum, frame):
global DRAIN_REQUESTED
logger.info("SIGUSR1 received — drain requested, will exit after current batch")
DRAIN_REQUESTED = True
signal.signal(signal.SIGUSR1, request_drain)
# main poll loop:
while not DRAIN_REQUESTED:
msg = pull_from_nats()
if msg is None:
if DRAIN_REQUESTED:
break
continue
process_batch(msg)
ack(msg)
logger.info("drain complete, exiting cleanly")
sys.exit(0)
Trigger: supervisorctl signal SIGUSR1 <program>, or any other process supervisor that can deliver a signal to the PID. Falls back to existing SIGTERM behavior if not implemented (so this is a strict superset).
Alternative considered: drain flag file
A ~/.drain flag file checked at the top of the poll loop. Simpler, but introduces shared filesystem state and a cleanup race. Rejected in favor of SIGUSR1.
Why this matters now
Without the drain handler, the only way to recycle a worker is SIGTERM (immediate). That kills any in-flight batch mid-processing, leaves the NATS message unacked, and forces redelivery on another worker after ack_wait. Wasted GPU cycles + observable as num_redelivered climbing.
With the drain handler, a routine recycle is invisible from the job's perspective: the batch finishes, the message is acked, and the worker comes back fresh.
This unblocks both health-driven recycle (e.g. "log mtime > 10 min while pending > 0 → recycle this PID") and time-based rolling recycle (e.g. low-traffic daily restart) as cheap operator-side scripts.
Effort + risk
- Effort: ~2–3 hours. Single file, single signal handler, single change to the poll loop.
- Risk: low. Falls back to existing SIGTERM behavior when not used. Worst case during draft: handler missed and worker SIGKILL'd at supervisor's
stopwaitsecs — same as today.
Acceptance criteria
Out of scope
Related
Background
Silent slow-failure modes have been observed in worker processes during sustained job ingestion. They are tracked in:
Each of these is being investigated separately, but they share a property: the worker process keeps running while no longer making progress, and the supervisor sees
RUNNING. The result is wasted GPU cycles, unacked NATS tasks, and downstream redelivery noise — sometimes for hours before an operator notices.A defense-in-depth measure is to recycle the worker process periodically, so any unknown leak/hang mode becomes noise rather than an incident. Recycling is not a substitute for fixing the underlying bugs, but it is a useful floor of correctness while those fixes land.
Scope of this issue
This issue covers the worker-side change required to make recycling safe and ergonomic:
The operator-side pieces (cron schedules, health-driven scripts, container-level health checks) live in private deployment tooling and are out of scope for this issue.
A separate change in the platform repo will add
--max-tasks-per-childto the Celery result-callback workers; that is a different fork-pool runtime and is not addressed here.Proposal: SIGUSR1 = drain-and-exit
Standard Unix signal flow. Operator (or the supervisor's restart command) sends
SIGUSR1, the worker sets a flag, finishes the in-flight batch, acks NATS, and exits cleanly with status0. Whatever process supervisor is in use (supervisord, systemd, dockerrestart: always) respawns it with a fresh DataLoader / FD pool / RSS.Trigger:
supervisorctl signal SIGUSR1 <program>, or any other process supervisor that can deliver a signal to the PID. Falls back to existing SIGTERM behavior if not implemented (so this is a strict superset).Alternative considered: drain flag file
A
~/.drainflag file checked at the top of the poll loop. Simpler, but introduces shared filesystem state and a cleanup race. Rejected in favor of SIGUSR1.Why this matters now
Without the drain handler, the only way to recycle a worker is
SIGTERM(immediate). That kills any in-flight batch mid-processing, leaves the NATS message unacked, and forces redelivery on another worker afterack_wait. Wasted GPU cycles + observable asnum_redeliveredclimbing.With the drain handler, a routine recycle is invisible from the job's perspective: the batch finishes, the message is acked, and the worker comes back fresh.
This unblocks both health-driven recycle (e.g. "log mtime > 10 min while pending > 0 → recycle this PID") and time-based rolling recycle (e.g. low-traffic daily restart) as cheap operator-side scripts.
Effort + risk
stopwaitsecs— same as today.Acceptance criteria
stopwaitsecsrecommendation noted in deploy docs (e.g. 600s) so process supervisors give the in-flight batch time to finishOut of scope
max_tasks_per_child(separate change in the platform repo)Related