Skip to content

A link the broker refuses after the task body ran records FAILURE and acks the message, so the link is never sent #10725

Closed

Description

@aviadr1

Checklist

  • I have verified that the issue exists against the main branch of Celery.
  • This has already been asked to the discussions forum first.
  • I have read the relevant section in the
    contribution guide
    on reporting bugs.
  • I have checked the issues list
    for similar or identical bug reports.
  • I have checked the pull requests list
    for existing proposed fixes.
  • I have checked the commit log
    to find out if the bug was already fixed in the main branch.
  • I have included all related issues and possible duplicate issues
    in this issue (If there are none, check this box anyway).
  • I have tried to reproduce the issue with pytest-celery and added the reproduction script below.

Mandatory Debugging Information

  • I have included the output of celery -A proj report in the issue.
  • I have verified that the issue exists against the main branch of Celery.
  • I have included the contents of pip freeze in the issue (the packages that matter; the environment also has requirements/test.txt).
  • I have included all the versions of all the external dependencies required
    to reproduce this bug.

Optional Debugging Information

  • I have tried reproducing the issue on more than one Python version
    and/or implementation.
  • I have tried reproducing the issue on more than one message broker and/or
    result backend.
  • I have tried reproducing the issue on more than one version of the message
    broker and/or result backend.
  • I have tried reproducing the issue on more than one operating system.
  • I have tried reproducing the issue on more than one workers pool.
  • I have tried reproducing the issue with autoscaling, retries,
    ETA/Countdown & rate limits disabled.
  • I have tried reproducing the issue after downgrading
    and/or upgrading Celery and its dependencies.

Related Issues and Possible Duplicates

Related Issues

Possible Duplicates

  • None

Environment & Settings

Celery version: 5.7.0a1 (recovery), main at f0b1320

celery report Output:

software -> celery:5.7.0a1 (recovery) kombu:5.7.0a1 py:3.12.12
            billiard:4.3.0 redis:8.1.0
platform -> system:Linux arch:64bit, ELF
            kernel version:6.18.33.2-microsoft-standard-WSL2 imp:CPython
loader   -> celery.loaders.app.AppLoader
settings -> transport:redis results:redis://localhost:6379/11

broker_url: 'redis://:********@localhost:6379/10'
result_backend: 'redis://localhost:6379/11'
deprecated_settings: None
task_acks_late: True
worker_prefetch_multiplier: 1

Steps to Reproduce

Required Dependencies

  • Minimal Python Version: N/A or Unknown (reproduced on 3.12.12)
  • Minimal Celery Version: N/A or Unknown (reproduced on main, f0b1320)
  • Minimal Kombu Version: N/A or Unknown (5.7.0a1)
  • Minimal Broker Version: N/A or Unknown (Redis 7.4.11)
  • Minimal Result Backend Version: N/A or Unknown (Redis 7.4.11)
  • Minimal OS and/or Kernel Version: N/A or Unknown (Linux, Docker)
  • Minimal Broker Client Version: N/A or Unknown (redis-py 8.1.0)
  • Minimal Result Backend Client Version: N/A or Unknown (redis-py 8.1.0)

Python Packages

pip freeze Output:

amqp==5.4.0
billiard==4.3.0
celery @ file:///src (main, f0b132007)
click==8.5.0
kombu==5.7.0a1
redis==8.1.0
vine==5.1.0

Other Dependencies

Details

Redis 7.4.11 as broker and result backend.

Minimally Reproducible Test Case

With a real worker: one task (send_message) with a link (announce) and an error callback (report_failure), task_acks_late=True, celery worker --pool=prefork --concurrency=1, Redis as broker and backend. In the worker, the first Signature.apply_async the task's execution makes (the link) raises kombu's OperationalError("[Errno 111] Connection refused"), as a broker that is down, or at maxmemory with noeviction, would. Then the worker is started again:

Details

                                      state     message sent   what the callbacks announced   broker
normal operation                      SUCCESS   1              sent                           queue 0, unacked 0
the broker refused the task's link    FAILURE   1              failed: OperationalError       queue 0, unacked 0

The contract that produces this, t/due_work/test_worker_contract.py, is in the PR that follows.

Expected Behavior

The task's body ran and its effect happened. Either the link is published later, or the message stays unacknowledged so the task is delivered again. In both cases the chain continues, and the task isn't recorded as a failure it didn't have.

Actual Behavior

The side effect happened, the result says FAILURE, the error callback fires, and the message is acknowledged even with acks_late. So the link is never sent, and a chain stops there for good.

_dispatch_callbacks_and_chain runs before mark_as_done, and only EncodeError is handled around it (L628). A broker error reaches the outer handler, which records FAILURE and fires the errbacks. Request.on_failure then acknowledges, because task_acks_on_failure_or_timeout defaults to True. Dispatch isn't atomic either: with several links, the ones before the refused one are already sent.

It also happens with a broker that really refuses the write, not only an injected error: with the Redis broker full (maxmemory 1, maxmemory-policy noeviction) when the link is published, Redis rejects the publish with OutOfMemoryError, kombu raises OperationalError, and the task ends FAILURE with its error callback fired, the message acknowledged (queue 0, unacked 0) and the link never sent, even after the worker restarts. A broker that only pauses writes (CLIENT PAUSE ... WRITE) does not trigger it: the publish waits and succeeds. The script is in this comment on #10726.

Aside: how this was found. This came from pytest-obligation, an open-source pytest plugin for background work that must not be lost, run twice or recorded wrongly. Given one task and how to see what it did, it generates the failure cases on a real celery worker: it kills the pool child at each of Celery's stages, makes a hook raise, refuses a publish, restarts the worker, and compares every outcome with a normal run. In Celery it produced #10724 (fixed by #10752 and #10759), #10725 and #10954. Each stays pinned as a strict xfail, so a fix shows up as a change, and so does a regression; when the two #10724 fixes landed, the unchanged contract flagged exactly those two cases. It runs nightly against Celery's main on gigaverse-app/celery, and #10726 shows what adopting it in Celery's own CI would look like.

Written with Claude Code at Aviad Rozenhek's direction; every result above was produced by running the code shown.

Activity

  1. aviadr1 commented on Sep 28, 2026

    @aviadr1
    Author

    I'm Claude Opus 5.5, AI can make mistakes. The next step fell off the chain.

    ELI5: what can break

    A chain is a relay race: each task hands the baton to the next one.

      task A (runs the body)                  broker                        task B (the link)
      ──────────────────────                  ──────                        ─────────────────
      does its work: payment captured  ✔
      hands the baton to B ──────────────►   ✘ "connection refused"
                                              (restarting, or full:
                                               maxmemory + noeviction)
      "that failed" ─► records A = FAILURE    (A's work happened!)
      error callback: "failed"
      acks A's message ──────────────────►   A's message deleted        never starts
    

    With acks_late you'd expect the message to stay unacknowledged, so A is delivered again and hands the baton over next time. Instead the refused hand-off is recorded as A's own failure, and A's message is acknowledged (task_acks_on_failure_or_timeout defaults to True). Nothing is left to deliver.

    • The work happened, but the record says FAILURE. Anyone who re-runs failed tasks captures the payment again.
    • The chain stops for good. B, and everything after it, never runs.
    • With several links, the ones before the refused one were already sent.

    #10726 adds the due-work-harness contract that found this by refusing the task's link as one of Celery's failure stages.

  2. ChristianMurphy commented on Oct 21, 2026

    @ChristianMurphy
    Contributor

    Chords on the Redis result backend fail the same way. If the last header finishes while the broker link is down, Celery's one attempt to send the body fails. It then marks the whole chord as failed, and nothing tries again. I see this on 5.7.0rc1, main (d6d754e), and every older release I tried, back to 4.4.7.

    Script. It needs Redis on localhost:6379.
    from unittest.mock import patch
    
    from kombu.exceptions import OperationalError
    
    from celery import Celery, signature, states, uuid
    from celery.app.task import Context
    
    app = Celery(broker="memory://", backend="redis://localhost:6379/0")
    backend = app.backend
    group_id, header_ids = uuid(), [uuid(), uuid()]
    body = signature("body", app=app).freeze()  # gives the body a task id
    backend.set_chord_size(group_id, 2)
    
    
    def header_done(index, value):
        request = Context(id=header_ids[index], group=group_id, group_index=index,
                          chord=signature("body", app=app, task_id=body.id))
        backend.on_chord_part_return(request, states.SUCCESS, value)
    
    
    header_done(0, "A")
    with patch("celery.canvas.Signature.delay", side_effect=OperationalError("broker link reset")):
        header_done(1, "B")  # the last header finishes while the broker link is down
    print("body state:", backend.get_task_meta(body.id, cache=False)["status"])
    
    with patch("celery.canvas.Signature.delay") as body_delay:
        header_done(1, "B")  # the same header runs again once the broker is back
    print("body sent on retry:", body_delay.called)

    Celery 4.4.7 and 5.0.5 have no set_chord_size(). On those, I passed the size as chord_size on the body signature and got the same output.

    body state: FAILURE
    body sent on retry: False
    

    Celery stores FAILURE for the body, with ChordError("Callback error: ..."), and deletes the chord's keys. When the header runs again, there is no chord left to finish. Nothing ever sends the body, and no task ever reads the header results. In a fault test on 5.6.3 with real workers and short network drops between one worker and RabbitMQ, this happened to 1 of 60 chords.

  3. its-amann commented on Oct 27, 2026

    @its-amann
    Contributor

    I opened #10975 with a fix for this, including unit tests for the link and chord refusal cases.

  4. modified the milestones: 5.7.0, 5.7.x on Oct 27, 2026
  5. added a commit that references this issue on Nov 2, 2026
    9378c31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions