feat(celery): cap worker-loss redeliveries to abandon repeatedly-killed tasks - #1284
Conversation
…ed tasks When a worker is killed mid-task (cgroup OOMKill of the child, or a node memory-pressure eviction), task_acks_late + task_reject_on_worker_lost cause the task to be redelivered with the same Celery id. A task that OOMs on every run would loop forever (OOM -> redeliver -> OOM) and the surrounding chord/workflow could never complete. Add task_max_retries (-1 = disabled). run_command counts redeliveries on the Celery result backend, keyed by the stable Celery id, and once the cap is exceeded abandons the task: it returns a forwarded result list with a FAILURE Error appended instead of re-running, so the chord proceeds and the workflow finishes with a clear 'abandoned after N retries' error (mirroring the inner memory-limit warning). The counter is backend-agnostic: it uses the result backend's generic key/value interface (atomic incr when available e.g. Redis/Memcached, else a get/set read-modify-write that every KV backend implements - filesystem, S3, GCS, ...). It is not tied to Redis or to any Secator data backend (Mongo/Postgres/SQLite). Backends with neither (database/ RPC) degrade safely to 'cap disabled'. Inert by default (task_max_retries=-1 and task_acks_late=False); enabling requires task_acks_late + task_reject_on_worker_lost and raising broker_visibility_timeout above the max task lifetime. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…loss cancel flag Completes the worker-loss redelivery cap with the review fixes + the L1 delivery flag: - Off-by-one: the initial delivery no longer counts as a retry. Extracted worker_loss_retries_exhausted(delivery_count, max_retries) — redeliveries = delivery_count - 1, so task_max_retries=3 allows the initial run + 3 redeliveries before abandoning (was 2). Unit-tested at the boundary. - Key expiry: worker-loss counter keys now get a best-effort TTL tied to result_expires (via _expire_worker_loss_key) on both the atomic-incr and the get/set paths, so they don't accumulate forever. - L1: add worker_cancel_long_running_tasks_on_connection_loss config flag (default False) wired into app.conf, alongside the existing task_acks_late / task_reject_on_worker_lost. Enables clean redelivery of a connection-lost worker's in-flight tasks. Inert until enabled in deployment. Note: the terminal-doc no-op guard (a redelivered task whose runner doc is already done) is deferred — it depends on stable runner identity (on_build, the L2 PR) to find 'its' doc, and is ineffective without it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The abandon message said 'after N retries' using task_max_retries, which read
oddly at max_retries=0 ('after 0 retries') because a redelivery still occurs
even with 0 retries — the broker redelivers a lost task under acks_late; that
is NOT a re-run of the work. Thread the actual delivery_count through and
report it separately from the cap:
Task httpx abandoned after 5 delivery attempts (retry cap: 3; worker
repeatedly lost — likely OOM kill or node eviction).
Reads sensibly for any cap value (incl. 0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughAdds a configurable Celery worker-loss retry cap. New ChangesWorker-loss retry cap
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Broker
participant Worker
participant run_command
participant Backend
Broker->>Worker: redeliver task (worker lost)
Worker->>run_command: execute task
run_command->>Backend: bump_worker_loss_count(task_id)
Backend-->>run_command: delivery count
run_command->>run_command: worker_loss_retries_exhausted(count, max_retries)
alt exhausted
run_command->>run_command: abandon_task(...)
run_command-->>Broker: return Error result (abandoned)
else not exhausted
run_command-->>Broker: continue normal execution
end
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (6)
secator/celery.py (2)
341-344: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winGate the cap on
task_reject_on_worker_losttoo.The cap only targets worker-loss redeliveries; requiring both late acks and reject-on-worker-lost keeps the runtime behavior aligned with the feature’s activation contract.
Proposed fix
- if CONFIG.celery.task_max_retries != -1 and CONFIG.celery.task_acks_late: + if ( + CONFIG.celery.task_max_retries != -1 + and CONFIG.celery.task_acks_late + and CONFIG.celery.task_reject_on_worker_lost + ):🤖 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 `@secator/celery.py` around lines 341 - 344, The retry cap in the worker-loss redelivery path is only guarded by `task_acks_late`, but it should also require `task_reject_on_worker_lost` to match the feature contract. Update the condition in the `celery.py` task handling logic around `bump_worker_loss_count`, `worker_loss_retries_exhausted`, and `abandon_task` so the cap runs only when both `CONFIG.celery.task_acks_late` and `CONFIG.celery.task_reject_on_worker_lost` are enabled, keeping the worker-loss redelivery behavior aligned with the intended activation settings.
211-214: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winKeep TTL failures diagnosable.
The best-effort ignore is fine for unsupported
expire, but unexpected backend errors should be debug-logged instead of silently swallowed.Proposed fix
try: backend.expire(key, CONFIG.celery.result_expires) - except Exception: + except (AttributeError, NotImplementedError): pass + except Exception as e: # noqa: BLE001 + debug(f'worker-loss expire failed for {key}: {e}', sub='celery.state')🤖 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 `@secator/celery.py` around lines 211 - 214, The backend TTL update in the expire call is swallowing all failures silently, which hides unexpected backend issues. Update the try/except around backend.expire in the celery result-expiration path to keep the best-effort behavior for unsupported expire, but catch unexpected exceptions and emit a debug log with the error details instead of using a bare pass. Use the existing celery/backend expiration flow and the surrounding CONFIG.celery.result_expires context to locate the fix.Source: Linters/SAST tools
tests/unit/test_celery.py (4)
360-365: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueBlind
except Exception: passin cleanup silently hides delete failures.Ruff flags S110/BLE001 here. Since this is test-cleanup code, low risk, but a debug log would help diagnose flaky cross-test key collisions instead of silently swallowing errors.
🔧 Suggested fix
finally: try: app.backend.delete(key) app.backend.delete(app.backend.get_key_for_task(f'worker-loss-{task_id}-other')) - except Exception: - pass + except Exception as e: + print(f'cleanup failed: {e}')🤖 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 `@tests/unit/test_celery.py` around lines 360 - 365, The cleanup block in the test teardown is swallowing all failures with a blind exception handler, which hides backend delete issues and makes flaky key-collision problems hard to diagnose. Update the cleanup around app.backend.delete and app.backend.get_key_for_task in the test_celery cleanup path to catch the exception explicitly and emit a debug log with the key information before continuing, keeping the teardown non-fatal but observable.Source: Linters/SAST tools
349-349: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winLocal
appre-imports shadow the module-level import (flake8 F811).Static analysis flags redefinition of
appat each of these lines since it's already imported at module level. Per the project's flake8 config (max-line-length=120, ignoring only W191/E101/E128/E265/W605), F811 is not suppressed and should be fixed.🔧 Suggested fix
def test_bump_worker_loss_count_get_set_fallback(self): """Counter increments via the generic get/set fallback (any KV backend, e.g. filesystem).""" - from secator.celery import app, bump_worker_loss_count + from secator.celery import bump_worker_loss_countApply the analogous change at lines 370, 379, and 415 (drop the redundant
appimport, relying on the module-level import instead).Also applies to: 370-370, 379-379, 415-415
🤖 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 `@tests/unit/test_celery.py` at line 349, Remove the redundant local re-imports of app in the celery test cases to avoid shadowing the module-level import and triggering flake8 F811. Update the affected test blocks in test_celery by keeping the existing module-level app import and only importing the additional symbols needed there, including the sections around bump_worker_loss_count and the analogous cases at the other referenced spots.Sources: Coding guidelines, Linters/SAST tools
385-391: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winBare
returnsilently skips the test instead of reporting it as skipped.If
httpx not in TEST_TASKS, the test method returns early without any assertion, making the test appear to "pass" in CI while never actually validatingabandon_task's failure-result behavior. This hides missing coverage.🔧 Suggested fix
from secator.tasks import httpx from secator.celery import abandon_task - if httpx not in TEST_TASKS: - return + if httpx not in TEST_TASKS: + self.skipTest('httpx task not available in TEST_TASKS')🤖 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 `@tests/unit/test_celery.py` around lines 385 - 391, The test in test_abandon_task_returns_failure_error should not use a bare early return when httpx is absent from TEST_TASKS, because that makes the case look like a passing test instead of a skipped one. Update the test method to explicitly mark it as skipped using the test framework’s skip mechanism before exercising abandon_task, so the intent is visible in CI while keeping the failure-result behavior covered when httpx is available.
381-382: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winOver-indented continuation line (flake8 E127).
Line 382's continuation for the
with ... , \statement is over-indented for visual indent. E127 is not in the project's flake8 ignore list.🔧 Suggested fix
with patch.object(app.backend, 'incr', side_effect=NotImplementedError, create=True), \ - patch.object(app.backend, 'get', side_effect=NotImplementedError, create=True): + patch.object(app.backend, 'get', side_effect=NotImplementedError, create=True):🤖 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 `@tests/unit/test_celery.py` around lines 381 - 382, The `with patch.object(...)` statement in `test_celery.py` has an over-indented continuation line that triggers flake8 E127. Adjust the indentation of the continuation after the backslash in the `with` block so it matches the expected visual indent style, keeping the line aligned consistently with the surrounding test code in `test_celery.py`.Sources: Coding guidelines, Linters/SAST tools
🤖 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 `@secator/celery.py`:
- Around line 336-344: The early abandon path in the task redelivery cap check
returns an abandoned task without the request context attached, so ensure
`context` is added to `opts` before calling `abandon_task` in the `worker-loss
redelivery cap` block inside `celery.py`. Update the logic around
`bump_worker_loss_count`, `worker_loss_retries_exhausted`, and the
`abandon_task` call so the abandoned task always includes `celery_id`,
`worker_name`, and `routing_key` from `self.request` even when `opts` did not
already contain `context`.
In `@secator/config.py`:
- Line 77: The task_max_retries config in Config currently allows invalid values
below -1, which can cause run_command and worker_loss_retries_exhausted to
abandon tasks immediately; update the Config field definition for
task_max_retries to enforce a minimum of -1 using the existing Pydantic Field
pattern used elsewhere in secator/config.py.
---
Nitpick comments:
In `@secator/celery.py`:
- Around line 341-344: The retry cap in the worker-loss redelivery path is only
guarded by `task_acks_late`, but it should also require
`task_reject_on_worker_lost` to match the feature contract. Update the condition
in the `celery.py` task handling logic around `bump_worker_loss_count`,
`worker_loss_retries_exhausted`, and `abandon_task` so the cap runs only when
both `CONFIG.celery.task_acks_late` and
`CONFIG.celery.task_reject_on_worker_lost` are enabled, keeping the worker-loss
redelivery behavior aligned with the intended activation settings.
- Around line 211-214: The backend TTL update in the expire call is swallowing
all failures silently, which hides unexpected backend issues. Update the
try/except around backend.expire in the celery result-expiration path to keep
the best-effort behavior for unsupported expire, but catch unexpected exceptions
and emit a debug log with the error details instead of using a bare pass. Use
the existing celery/backend expiration flow and the surrounding
CONFIG.celery.result_expires context to locate the fix.
In `@tests/unit/test_celery.py`:
- Around line 360-365: The cleanup block in the test teardown is swallowing all
failures with a blind exception handler, which hides backend delete issues and
makes flaky key-collision problems hard to diagnose. Update the cleanup around
app.backend.delete and app.backend.get_key_for_task in the test_celery cleanup
path to catch the exception explicitly and emit a debug log with the key
information before continuing, keeping the teardown non-fatal but observable.
- Line 349: Remove the redundant local re-imports of app in the celery test
cases to avoid shadowing the module-level import and triggering flake8 F811.
Update the affected test blocks in test_celery by keeping the existing
module-level app import and only importing the additional symbols needed there,
including the sections around bump_worker_loss_count and the analogous cases at
the other referenced spots.
- Around line 385-391: The test in test_abandon_task_returns_failure_error
should not use a bare early return when httpx is absent from TEST_TASKS, because
that makes the case look like a passing test instead of a skipped one. Update
the test method to explicitly mark it as skipped using the test framework’s skip
mechanism before exercising abandon_task, so the intent is visible in CI while
keeping the failure-result behavior covered when httpx is available.
- Around line 381-382: The `with patch.object(...)` statement in
`test_celery.py` has an over-indented continuation line that triggers flake8
E127. Adjust the indentation of the continuation after the backslash in the
`with` block so it matches the expected visual indent style, keeping the line
aligned consistently with the surrounding test code in `test_celery.py`.
🪄 Autofix (Beta)
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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 864e2469-9da8-4b62-8df1-f647be174aa1
📒 Files selected for processing (3)
secator/celery.pysecator/config.pytests/unit/test_celery.py
- Gate the cap on task_reject_on_worker_lost too (it's what actually re-queues a task on abrupt worker death; late acks alone don't), matching the activation contract. - Attach the request context (celery_id/worker_name/routing_key) before the early abandon return, so an abandoned task doc isn't missing its identity when opts arrived without a context. - Validate task_max_retries with Field(ge=-1) so a value < -1 can't enable the cap and abandon tasks on the first delivery. - _expire_worker_loss_key: swallow only AttributeError/NotImplementedError (unsupported TTL); debug-log unexpected backend errors instead of hiding them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtTyzcUmPYxM5nfp7MnMVd
|
Addressed the CodeRabbit findings (commit pushed):
|
…re skip) - Drop the local `from secator.celery import app` re-imports (app is already imported at module level) — clears flake8 F811 that would fail `secator test lint`. - Nest the two patch.object context managers instead of a backslash continuation (clears E127 over-indent). - Use self.skipTest() instead of a bare return so the httpx-missing case is reported as skipped, not silently passed. (Left the best-effort `except Exception: pass` in the test's finally cleanup — it's test-only key teardown; a cleanup failure shouldn't fail the test.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtTyzcUmPYxM5nfp7MnMVd
🤖 I have created a release *beep* *boop* --- ## [0.40.0](v0.39.0...v0.40.0) (2026-07-06) ### Features * **celery:** cap worker-loss redeliveries to abandon repeatedly-killed tasks ([#1284](#1284)) ([9de0592](9de0592)) * **hooks:** on_build — stable runner identity across redeliveries (L2) ([#1202](#1202)) ([e0b16b4](e0b16b4)) * **runners:** redact sensitive task options from serialized/printed state ([#1232](#1232)) ([0203f02](0203f02)) * **vulnerability:** add status field + carry-over across re-scans ([#1240](#1240)) ([60154dd](60154dd)) ### Performance Improvements * **runners:** route empty-results mark_started to the small pool ([#1281](#1281)) ([56866d6](56866d6)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
Re-extracted from the closed #1199 — this generic Celery worker-reliability fix was folded into the
ai-resiliencybranch (#1241, AI-task hardening, basecanary) and its standalone PR closed. It's general worker-pool infra (nothing AI-specific), so it belongs onmainon its own timeline. Cherry-picked clean onto currentmain.What it does
Under
task_acks_late+task_reject_on_worker_lost(prod config), a task that dies with its worker (SIGKILL / OOM / node eviction) is redelivered with the same Celery id. A task that OOMs on every run would loop forever (OOM → redeliver → OOM), blocking its chord indefinitely. This caps redeliveries:run_commandcounts worker-loss redeliveries (Rediscelery-task-meta-worker-loss-<id>); oncetask_max_retriesis exceeded it abandons the task with a clearabandoned after N delivery attemptsresult so the chord/workflow can finish.task_max_retries=-1disables the cap (default). Also adds the connection-loss cancel flag.Validation
Exercised end-to-end earlier (prefork child-loss → requeue → abandon at cap, 6/6).
tests/unit/test_celery.py: 23 passed on currentmain; flake8 clean.Note
feature:worker-reliability. Companion PR extracts the eviction-finalize half (was #1203). Prod workers run-P prefork -c 1, so the fast child-loss path is live.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes