Skip to content

feat(celery): cap worker-loss redeliveries to abandon repeatedly-killed tasks - #1199

Closed
ocervell wants to merge 3 commits into
mainfrom
feat/worker-loss-retry-cap
Closed

feat(celery): cap worker-loss redeliveries to abandon repeatedly-killed tasks#1199
ocervell wants to merge 3 commits into
mainfrom
feat/worker-loss-retry-cap

Conversation

@ocervell

@ocervell ocervell commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Problem

When a worker is killed mid-task — a cgroup OOMKill of the child process, or a node memory-pressure eviction — the task never returns a result. With task_acks_late + task_reject_on_worker_lost, the broker redelivers the task (same Celery id) to a fresh worker. That's the desired recovery, except for a task that OOMs on every run (e.g. a memory spike the inner task_memory_limit_mb monitor doesn't catch in time): it loops forever — OOM → redeliver → OOM — and the surrounding chord/workflow can never complete.

This was hit in prod: a headless katana task on worker-large was evicted (The node was low on resource: memory), the Job failed (BackoffLimitExceeded), and the workflow stalled.

Change

Add celery.task_max_retries (-1 = disabled). run_command counts redeliveries (keyed by the stable Celery id) and, once the cap is exceeded, abandons the task instead of re-running it: it returns a forwarded result list with a FAILURE Error appended ("Task <name> abandoned after N retries (worker repeatedly lost — likely OOM kill or node eviction)."), so the chord proceeds and the workflow finishes cleanly — mirroring the existing inner memory-limit warning.

Backend-agnostic counter

The counter lives on the Celery result backend's generic key/value interface, not Redis internals and not any Secator data backend (Mongo/Postgres/SQLite):

  • atomic incr when the backend provides it (Redis, Memcached) — race-free;
  • get/set read-modify-write otherwise (filesystem, S3, GCS, cache, …) — safe because same-task redeliveries are sequential;
  • backends with neither (database/RPC) degrade to cap disabled (no crash).

Safety / rollout

Inert by defaulttask_max_retries=-1 and task_acks_late=False ship unchanged, so this commit changes no behavior. Enabling the cap requires, at deploy time:

  • task_acks_late=1 + task_reject_on_worker_lost=1,
  • task_max_retries set (e.g. 3),
  • ⚠️ broker_visibility_timeout raised above the max task lifetime (tasks run up to activeDeadlineSeconds=10800); leaving it at the default 1h would duplicate-execute long tasks once acks_late is on.

Tests

tests/unit/test_celery.py — get/set fallback (real filesystem backend), atomic-incr path, unsupported-backend degradation, and the abandon path returning a FAILURE error. Full file: 22 passing.

Scope note

This fully covers the child-OOMKill retry loop. A whole-pod eviction kills the parent too, so its redelivery comes via the slower Redis visibility-timeout restore; that case is primarily addressed by task-profile sizing (see #1198) + Guaranteed QoS. Externally un-sticking an already-stuck chord is a separate follow-up (Layer 3).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added configurable worker-loss retry limit for tasks. When a task exceeds the maximum allowed retries after worker loss, it is automatically abandoned and marked as failed rather than retried indefinitely.

…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>
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1fccfffa-1662-495e-9226-92d996e009f2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

A new task_max_retries integer field (default -1) is added to the Celery config schema. Two helper functions, bump_worker_loss_count and abandon_task, are added to secator/celery.py. The run_command task is extended to check the worker-loss counter and return an abandoned result when the cap is exceeded. Unit tests cover all three new behaviors.

Changes

Worker-Loss Retry Cap

Layer / File(s) Summary
Config field and Error import
secator/config.py, secator/celery.py
Celery config gains a task_max_retries: int = -1 field; Error is added to the output type imports in celery.py.
bump_worker_loss_count and abandon_task helpers
secator/celery.py
bump_worker_loss_count tracks per-task redelivery counts using atomic incr when available, falling back to get/set. abandon_task appends an Error to the forwarded results when the cap is exceeded, returning via chain_results or task.results depending on the backend.
run_command cap enforcement
secator/celery.py
When task_max_retries != -1 and task_acks_late is enabled, run_command calls bump_worker_loss_count and returns abandon_task(...) if the delivery count exceeds the configured cap.
Unit tests
tests/unit/test_celery.py
TestWorkerLossRetryCap tests the get/set fallback increment path, the atomic incr preference (via mock), the disabled path returning 0, and abandon_task producing exactly one error-typed result with "abandoned after" in the message.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • freelabz/secator#681: Modifies the same run_command Celery task function in secator/celery.py, overlapping directly with the new cap enforcement logic added here.

Poem

🐇 Hippity-hop, the worker's gone astray,
But now we count each loss along the way.
When retries exceed the configured cap,
We abandon the task — no more mishap!
bump_worker_loss_count keeps the tally true,
abandon_task says: "We're done with you!" 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely describes the main change: introducing a cap on Celery worker-loss redeliveries to abandon repeatedly-killed tasks, which directly reflects the feature added across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/worker-loss-retry-cap

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 and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
tests/unit/test_celery.py (2)

344-365: ⚡ Quick win

Consider logging exceptions in cleanup block.

The test cleanup properly uses a try-finally block, but the exception handler silently swallows all exceptions. While acceptable for cleanup code, logging the exception would aid debugging if cleanup fails.

📝 Proposed enhancement
 		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:
+				# Cleanup failures are non-fatal but worth noting for debugging
+				import logging
+				logging.debug(f'Test cleanup failed for {task_id}: {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 344 - 365, In the
test_bump_worker_loss_count_get_set_fallback method, the cleanup code in the
finally block contains an except Exception handler that silently passes without
logging. Replace the bare pass statement with a logging call to capture
exception details when the backend deletion fails, which will aid in debugging
cleanup issues. Use an appropriate logger (such as logging.exception or a test
logger) to record the exception that occurs during the app.backend.delete
operations.

Source: Linters/SAST tools


393-393: ⚡ Quick win

Use isinstance instead of accessing _type attribute.

Filtering errors by checking the private _type attribute is inconsistent with the established codebase pattern. The errors() method in secator/runners/_base.py:368-369 uses isinstance(r, Error) for type checking.

♻️ Proposed refactor for consistency
+		from secator.output_types import Error
 		results = abandon_task('httpx', ['example.com'], {'context': {}}, [])
-		errors = [r for r in results if r._type == 'error']
+		errors = [r for r in results if isinstance(r, Error)]
 		self.assertEqual(len(errors), 1)
🤖 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 393, Replace the private attribute access
check in the list comprehension that filters errors to use isinstance() for
consistency with the established codebase pattern. The line that checks r._type
== 'error' should be refactored to use isinstance(r, Error) instead, matching
the approach used elsewhere in secator/runners/_base.py. This improves code
consistency and avoids relying on private attributes.
🤖 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 307-310: The comparison logic in the task max retries check is off
by one. The bump_worker_loss_count function returns the total delivery count
(1-indexed), where 1 represents the initial delivery and subsequent values
represent redeliveries. To correctly compare against the retry cap, subtract 1
from the delivery_count to get the actual number of redeliveries before
comparing against CONFIG.celery.task_max_retries. Additionally, ensure that only
non-negative values of task_max_retries are treated as enabled caps so that a
value of 0 means zero retries allowed (not abandon on first delivery).
- Around line 234-237: The code stores a plain string value using
backend.set(key, str(count)), but Celery result backends expect serialized bytes
from an encoder, and byte-oriented KV backends may reject string payloads.
Encode the string counter value to bytes before passing it to backend.set by
using the appropriate encoder method (similar to how other values are encoded in
this context) to ensure compatibility with all backend types.
- Around line 221-237: The worker-loss counter keys created in
bump_worker_loss_count() do not have an expiry time set, causing them to
accumulate indefinitely on the backend and consume storage. After the
backend.incr(key) call in the atomic increment path and after the
backend.set(key, str(count)) call in the fallback get/set path, add expiry using
backend.expire(key, CONFIG.celery.result_expires) within try-except blocks to
gracefully handle backends that do not support the expire operation.

In `@tests/unit/test_celery.py`:
- Line 382: The continuation line containing patch.object(app.backend, 'get',
side_effect=NotImplementedError, create=True) is over-indented, which violates
the Flake8 E127 rule that is not in the project's ignore list. Reduce the
indentation of this continuation line to align it properly with the surrounding
context, ensuring it follows proper continuation line indentation standards for
the multi-line statement.

---

Nitpick comments:
In `@tests/unit/test_celery.py`:
- Around line 344-365: In the test_bump_worker_loss_count_get_set_fallback
method, the cleanup code in the finally block contains an except Exception
handler that silently passes without logging. Replace the bare pass statement
with a logging call to capture exception details when the backend deletion
fails, which will aid in debugging cleanup issues. Use an appropriate logger
(such as logging.exception or a test logger) to record the exception that occurs
during the app.backend.delete operations.
- Line 393: Replace the private attribute access check in the list comprehension
that filters errors to use isinstance() for consistency with the established
codebase pattern. The line that checks r._type == 'error' should be refactored
to use isinstance(r, Error) instead, matching the approach used elsewhere in
secator/runners/_base.py. This improves code consistency and avoids relying on
private attributes.
🪄 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: 72edcaa0-2aa1-4d1c-b0f0-5f4784afe9e0

📥 Commits

Reviewing files that changed from the base of the PR and between 4e495a6 and 0dbd12d.

📒 Files selected for processing (3)
  • secator/celery.py
  • secator/config.py
  • tests/unit/test_celery.py

Comment thread secator/celery.py
Comment on lines +221 to +237
# Preferred: atomic incr (Redis, Memcached) — race-free.
try:
return backend.incr(key)
except NotImplementedError:
pass # Backend has no atomic counter; fall back to get/set below.
except Exception as e:
debug(f'worker-loss incr failed for {task_id}: {e}', sub='celery.state')
return 0

# Fallback: get/set, implemented by every key/value result backend (filesystem, S3, GCS, ...).
# Worker-loss redeliveries of the same task id are sequential (only one attempt runs at a
# time), so a non-atomic read-modify-write is safe here.
try:
raw = backend.get(key)
count = (int(raw) if raw else 0) + 1
backend.set(key, str(count))
return count

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm whether counter keys are cleaned up in this PR and inspect Celery incr/expire support.
rg -n -C3 '\b(worker-loss|expire_worker_loss|delete\s*\()' --type=py

python - <<'PY'
import importlib
import inspect

for mod_name, cls_name in [
    ("celery.backends.redis", "RedisBackend"),
    ("celery.backends.cache", "CacheBackend"),
]:
    try:
        mod = importlib.import_module(mod_name)
        cls = getattr(mod, cls_name)
        print(f"\n## {mod_name}.{cls_name}.incr")
        print(inspect.getsource(cls.incr))
        if hasattr(cls, "expire"):
            print(f"\n## {mod_name}.{cls_name}.expire")
            print(inspect.getsource(cls.expire))
    except Exception as exc:
        print(f"\n## {mod_name}.{cls_name}: unavailable ({exc})")
PY

Repository: freelabz/secator

Length of output: 4006


🏁 Script executed:

# Find all calls to bump_worker_loss_count to see cleanup patterns
rg -n 'bump_worker_loss_count' --type=py -A 10

# Search for cleanup/deletion after bump_worker_loss_count calls
rg -n 'delete.*worker-loss|cleanup' --type=py

# Check if there's any expiry logic in the codebase for these keys
rg -n 'expire|ttl' secator/celery.py --type=py

Repository: freelabz/secator

Length of output: 6112


🏁 Script executed:

# Read the full bump_worker_loss_count function and surrounding context
head -n 280 secator/celery.py | tail -n 100

Repository: freelabz/secator

Length of output: 3460


🏁 Script executed:

# Search for where task completion/abandonment happens to see if cleanup occurs there
rg -n 'def.*task|@.*task' secator/celery.py -A 5 --type=py | head -100

Repository: freelabz/secator

Length of output: 2869


🏁 Script executed:

# Look for cleanup on successful task completion or in task result cleanup
rg -n 'mark_completed|on_success|mark_finished' secator/celery.py -A 5 --type=py

# Check if result_expires applies to worker-loss keys anywhere
rg -n 'result_expires' secator/ --type=py

# Check backend implementations for TTL behavior
rg -n 'def delete|def expire' secator/celery.py --type=py

Repository: freelabz/secator

Length of output: 1297


🏁 Script executed:

# Check if there's any cleanup code added after bump_worker_loss_count call
rg -n -A 15 'bump_worker_loss_count' secator/celery.py --type=py | grep -A 15 'line 308'

# Check if expire/delete is called after bump_worker_loss_count anywhere
rg -n -A 5 'bump_worker_loss_count' secator/ --type=py | grep -E '(delete|expire|cleanup)'

# Verify if the function was modified to include expiry
cat secator/celery.py | sed -n '203,241p'

Repository: freelabz/secator

Length of output: 1659


🏁 Script executed:

# Check the full context around the bump_worker_loss_count call at line 308
cat secator/celery.py | sed -n '300,320p'

# Check if there's any cleanup in the task completion path after the task runs
cat secator/celery.py | sed -n '278,350p'

Repository: freelabz/secator

Length of output: 3399


🏁 Script executed:

# Check if the backend delete/expire methods are available and used elsewhere
rg -n 'backend\.(delete|expire)' secator/ --type=py

# Verify the full flow: after task completes, is there any cleanup?
rg -n 'def run_command' secator/celery.py -A 80 --type=py | tail -60

Repository: freelabz/secator

Length of output: 2271


🏁 Script executed:

# Check if there's any automatic TTL/expiry applied to backend keys
rg -n 'get_key_for_task|backend.set' secator/celery.py -B 2 -A 2 --type=py

# Look for any cleanup scheduled or periodic tasks
rg -n '@.*periodic|cleanup|purge|expire' secator/ --type=py | head -20

# Check if the counter key is ever deleted in any path
rg -n 'delete.*worker-loss' secator/ --type=py

Repository: freelabz/secator

Length of output: 2061


🏁 Script executed:

# Check the Celery backend API documentation by looking at how set/incr are used
python3 -c "
import inspect
try:
    from celery.backends.base import BaseBackend
    print('BaseBackend.set signature:')
    print(inspect.signature(BaseBackend.set))
    print('\nBaseBackend.incr signature:')
    print(inspect.signature(BaseBackend.incr))
except ImportError:
    print('Celery not available in sandbox, checking method signatures in codebase')
"

# Check if backend.set is called with TTL anywhere in the codebase
rg -n 'backend\.set\(' secator/ -A 1 --type=py

Repository: freelabz/secator

Length of output: 222


Set TTL on worker-loss counter keys to prevent unbounded accumulation.

The bump_worker_loss_count() function increments/sets a custom worker-loss-{task_id} counter key (lines 221–237) but never expires it. With task capping enabled, each task creates a counter key that persists indefinitely on the backend, even after the task completes successfully or is abandoned. Over time, this accumulates stale keys, consuming backend storage.

Add an expiry aligned with CONFIG.celery.result_expires after incr and set calls, with graceful fallback for backends without expire support:

Suggested fix
+def expire_worker_loss_key(backend, key):
+	"""Expire a worker-loss counter key if the backend supports it."""
+	expire = getattr(backend, 'expire', None)
+	if callable(expire) and CONFIG.celery.result_expires > 0:
+		try:
+			expire(key, CONFIG.celery.result_expires)
+		except NotImplementedError:
+			pass
+		except Exception as e:
+			debug(f'worker-loss expire failed: {e}', sub='celery.state')
+
 	# Preferred: atomic incr (Redis, Memcached) — race-free.
 	try:
-		return backend.incr(key)
+		count = backend.incr(key)
+		expire_worker_loss_key(backend, key)
+		return count
 	except NotImplementedError:
 		pass  # Backend has no atomic counter; fall back to get/set below.
 	except Exception as e:
 		debug(f'worker-loss incr failed for {task_id}: {e}', sub='celery.state')
 		return 0

 	# Fallback: get/set, implemented by every key/value result backend (filesystem, S3, GCS, ...).
 	try:
 		raw = backend.get(key)
 		count = (int(raw) if raw else 0) + 1
 		backend.set(key, str(count))
+		expire_worker_loss_key(backend, key)
 		return count
🧰 Tools
🪛 Ruff (0.15.17)

[warning] 226-226: Do not catch blind exception: Exception

(BLE001)

🤖 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 221 - 237, The worker-loss counter keys
created in bump_worker_loss_count() do not have an expiry time set, causing them
to accumulate indefinitely on the backend and consume storage. After the
backend.incr(key) call in the atomic increment path and after the
backend.set(key, str(count)) call in the fallback get/set path, add expiry using
backend.expire(key, CONFIG.celery.result_expires) within try-except blocks to
gracefully handle backends that do not support the expire operation.

Comment thread secator/celery.py
Comment on lines +234 to +237
raw = backend.get(key)
count = (int(raw) if raw else 0) + 1
backend.set(key, str(count))
return count

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the installed Celery backend set() implementations for payload type expectations.
python - <<'PY'
import importlib
import inspect
import importlib.metadata

try:
    print("celery", importlib.metadata.version("celery"))
except Exception as exc:
    print("Could not determine Celery version:", exc)

for mod_name, cls_name in [
    ("celery.backends.filesystem", "FilesystemBackend"),
    ("celery.backends.s3", "S3Backend"),
    ("celery.backends.gcs", "GCSBackend"),
]:
    try:
        mod = importlib.import_module(mod_name)
        cls = getattr(mod, cls_name)
        print(f"\n## {mod_name}.{cls_name}.set")
        print(inspect.getsource(cls.set))
    except Exception as exc:
        print(f"\n## {mod_name}.{cls_name}: unavailable ({exc})")
PY

Repository: freelabz/secator

Length of output: 374


🏁 Script executed:

#!/bin/bash
# Find and read the secator/celery.py file, focusing on the bump_worker_loss_count function
fd -t f celery.py | head -5

Repository: freelabz/secator

Length of output: 163


🏁 Script executed:

#!/bin/bash
# Read the full context around lines 203-240 in secator/celery.py
if [ -f "secator/celery.py" ]; then
  wc -l secator/celery.py
  echo "---"
  sed -n '200,245p' secator/celery.py
fi

Repository: freelabz/secator

Length of output: 1814


🌐 Web query:

Celery result backend set method API str bytes payload type

💡 Result:

In Celery's result backend architecture, the set method is a mandatory abstract method defined in base backend classes (such as KeyValueStoreBackend) that handles the actual persistence of task data [1][2][3]. When Celery stores a result, it typically goes through a process of encoding the data into a serialized payload before passing it to the backend. The internal _store_result method calls self.encode(meta), which produces a bytes object representing the serialized task metadata [1][4]. This encoded bytes payload is then passed to _set_with_state, which delegates to the backend's set(key, value) method [2][4]. While the abstract interface for set(self, key, value) does not strictly enforce the type of the value at the Python interface level, it is designed to receive the serialized output of the encoder [2][5]. In practice, because Celery backends often use serializers (like JSON or pickle) that return bytes, the payload handled by set is expected to be bytes in most production implementations [1][5]. Key takeaways: - The set method is responsible for persisting data, typically as bytes [1][2]. - Data is serialized using self.encode() before reaching the set method [1][4]. - Backends that do not handle bytes natively (e.g., some that might expect strings) may perform their own conversions, but the core Celery contract revolves around the serialized bytes representation produced by the configured serializer [1][3][5].

Citations:


Encode the fallback counter value before backend.set.

Line 236 stores a str; Celery result backends are designed to handle serialized bytes from the encoder, and byte-oriented KV backends (filesystem, S3, GCS) can reject string payloads. On failure, the broad exception handler silently returns 0, disabling the cap. Store an encoded byte value instead.

🐛 Proposed fix
-		backend.set(key, str(count))
+		backend.set(key, str(count).encode())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
raw = backend.get(key)
count = (int(raw) if raw else 0) + 1
backend.set(key, str(count))
return count
raw = backend.get(key)
count = (int(raw) if raw else 0) + 1
backend.set(key, str(count).encode())
return count
🤖 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 234 - 237, The code stores a plain string
value using backend.set(key, str(count)), but Celery result backends expect
serialized bytes from an encoder, and byte-oriented KV backends may reject
string payloads. Encode the string counter value to bytes before passing it to
backend.set by using the appropriate encoder method (similar to how other values
are encoded in this context) to ensure compatibility with all backend types.

Comment thread secator/celery.py Outdated
Comment on lines +307 to +310
if CONFIG.celery.task_max_retries != -1 and CONFIG.celery.task_acks_late:
delivery_count = bump_worker_loss_count(self.request.id)
if delivery_count > CONFIG.celery.task_max_retries:
return abandon_task(name, targets, opts, results)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Compare against redeliveries, not total deliveries.

bump_worker_loss_count() returns 1 for the initial delivery, so task_max_retries = 0 abandons before the task ever runs. Treat only non-negative caps as enabled and compare delivery_count - 1 to the retry cap.

🐛 Proposed fix
-		if CONFIG.celery.task_max_retries != -1 and CONFIG.celery.task_acks_late:
+		if CONFIG.celery.task_max_retries >= 0 and CONFIG.celery.task_acks_late:
 			delivery_count = bump_worker_loss_count(self.request.id)
-			if delivery_count > CONFIG.celery.task_max_retries:
+			redelivery_count = max(delivery_count - 1, 0)
+			if redelivery_count > CONFIG.celery.task_max_retries:
 				return abandon_task(name, targets, opts, results)
🤖 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 307 - 310, The comparison logic in the task
max retries check is off by one. The bump_worker_loss_count function returns the
total delivery count (1-indexed), where 1 represents the initial delivery and
subsequent values represent redeliveries. To correctly compare against the retry
cap, subtract 1 from the delivery_count to get the actual number of redeliveries
before comparing against CONFIG.celery.task_max_retries. Additionally, ensure
that only non-negative values of task_max_retries are treated as enabled caps so
that a value of 0 means zero retries allowed (not abandon on first delivery).

Comment thread tests/unit/test_celery.py
from secator.celery import app, bump_worker_loss_count

with patch.object(app.backend, 'incr', side_effect=NotImplementedError, create=True), \
patch.object(app.backend, 'get', side_effect=NotImplementedError, create=True):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix continuation line indentation.

The continuation line is over-indented. Flake8 E127 is not in the project's ignore list per coding guidelines.

🔧 Proposed 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):
 			self.assertEqual(bump_worker_loss_count('task-abc'), 0)

As per coding guidelines, the project uses flake8 linting with specific ignored rules (W191, E101, E128, E265, W605), but E127 is not ignored.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
patch.object(app.backend, 'get', side_effect=NotImplementedError, create=True):
with patch.object(app.backend, 'incr', side_effect=NotImplementedError, create=True), \
patch.object(app.backend, 'get', side_effect=NotImplementedError, create=True):
self.assertEqual(bump_worker_loss_count('task-abc'), 0)
🧰 Tools
🪛 Flake8 (7.3.0)

[error] 382-382: continuation line over-indented for visual indent

(E127)

🤖 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 382, The continuation line containing
patch.object(app.backend, 'get', side_effect=NotImplementedError, create=True)
is over-indented, which violates the Flake8 E127 rule that is not in the
project's ignore list. Reduce the indentation of this continuation line to align
it properly with the surrounding context, ensuring it follows proper
continuation line indentation standards for the multi-line statement.

Sources: Coding guidelines, Linters/SAST tools

…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>
@ocervell

Copy link
Copy Markdown
Contributor Author

Pushed the review fixes + the L1 delivery flag (commit 634b2591):

  • Off-by-one fixed — extracted worker_loss_retries_exhausted(delivery_count, max_retries); the initial delivery no longer counts as a retry, so task_max_retries=3 now allows the initial run + 3 redeliveries. Unit-tested at the boundary.
  • Key expiry — worker-loss counter keys get a best-effort TTL (result_expires) on both the atomic-incr and get/set paths, so they no longer accumulate forever.
  • L1 flag — added worker_cancel_long_running_tasks_on_connection_loss (default False) wired into app.conf, alongside task_acks_late/task_reject_on_worker_lost. Inert until enabled in deployment.

Deferred: the terminal-doc no-op guard (a redelivered task whose runner doc is already done) depends on stable runner identity (on_build, PR #1202) and is ineffective without it — it'll land once #1202 merges. TestWorkerLossRetryCap is 6/6 green; full test_celery.py 23/23.

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>
@ocervell

ocervell commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Superseded — this work is already merged into ai-resiliency and ships to canary via #1241 (the branch is 0 commits ahead of ai-resiliency). Closing to keep the PR list clear; no work is lost, the commits live in ai-resiliency. Branch deleted.

@ocervell ocervell closed this Jul 5, 2026
@ocervell
ocervell deleted the feat/worker-loss-retry-cap branch July 5, 2026 22:28
ocervell added a commit that referenced this pull request Jul 6, 2026
…ed tasks (#1284)

**Re-extracted from the closed #1199** — this generic Celery
worker-reliability fix was folded into the `ai-resiliency` branch
(#1241, AI-task hardening, base `canary`) and its standalone PR closed.
It's general worker-pool infra (nothing AI-specific), so it belongs on
`main` on its own timeline. Cherry-picked clean onto current `main`.

## 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_command` counts worker-loss
redeliveries (Redis `celery-task-meta-worker-loss-<id>`); once
`task_max_retries` is exceeded it **abandons** the task with a clear
`abandoned after N delivery attempts` result so the chord/workflow can
finish. `task_max_retries=-1` disables 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 current `main`;
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](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a limit for repeated worker-loss retries so long-running jobs
can stop retrying after a configurable cap.
* Introduced an option to cancel in-flight tasks when the broker
connection is lost, helping prevent stuck background work.

* **Bug Fixes**
* Improved handling of interrupted tasks so workflows can complete with
a clear failure result instead of hanging indefinitely.
* Added safer retry counting to better track redelivered tasks across
worker losses.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:worker-reliability Celery worker reliability & redelivery

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant