Skip to content

Test: a real worker failing at each stage, for a task and a chord (#10724, #10725) - #10726

Open
aviadr1 wants to merge 19 commits into
celery:mainfrom
aviadr1:test/worker-lost-after-success
Open

aviadr1 wants to merge 19 commits into
celery:mainfrom
aviadr1:test/worker-lost-after-success

Conversation

@aviadr1

@aviadr1 aviadr1 commented Sep 28, 2026 •

Copy link
Copy Markdown

Note: Before submitting this pull request, please review our contributing
guidelines
.

I'm Claude Opus 5.5, AI can make mistakes. Two bugs down, the detector noticed.

TLDR

Today, nothing in Celery's suite fails a real worker at each stage of a task (pool child killed, hook raising, broker refusing a publish) and checks that the task, its link and its error callback still end up right. This PR adds that check as a test-only contract with pytest-obligation. It covers a single task and, new in this update, a chord. It records what each failure leaves, so a fix shows up as the contract going red, and a regression does too.

It has already done that once. After merging main, the unchanged contract reported that two histories it had pinned as bugs now behave like normal operation. Those are the two #10724 fixes, #10752 and #10759. Two problems remain: one is #10725, and one is now #10949. The new chord history also confirms on a real worker the chord case reported in a comment on #10725.

Refs #10724, #10725, #10949. No fix included; the fixes are yours to shape.

Note

How this was found, and an offer. These findings came from pytest-obligation, an open-source pytest plugin (Apache-2.0) for background work that must not be lost, run twice or recorded wrongly. You describe one task and how to see what it did. The plugin then 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 has produced #10724 (fixed by #10752 and #10759), #10725 and #10949. Each finding stays in the suite as a pinned outcome and a strict xfail, so a fix shows up as a change, and so does a regression. That already happened once: after main merged the two #10724 fixes, the unchanged contract flagged exactly those two cases as changed. The harness also grows. Since the first version of this PR it has added a chord history and two more guarantee families. Each new release runs the same contract against more ways to fail.

We'd like Celery to adopt it. This PR does that fully: the contract in t/due_work, plus a CI job (Linux, one Redis service, about 3.5 minutes with install, and a test-only dependency). If you want it, a follow-up PR can add pytest-obligation's PR report, a single comment on each PR summarizing what the contract proved.

Disclosure: the harness is written by Aviad Rozenhek (CTO, tapin.io), who directed this work. This PR and its findings were written with Claude Code, and every result in them was produced by running the code.

What the contract sees on main today

Each row is one run of a real celery worker (prefork, one child, task_acks_late=True), failing once at that stage, then the worker started again. Bold rows differ from normal operation.

One task (send_message, link announce, error callback report_failure):

History Before this update (pinned on f0b1320) Now (main merged)
normal operation SUCCESS, sent 1, sent same
died at task_prerun FAILURE, sent 0, failed same (expected: task_reject_on_worker_lost is off)
died at mark_as_done (link published, result not stored) FAILURE, sent 1, failed + sent same
died at task_postrun (SUCCESS stored) SUCCESS, sent 1, failed + sent (#10724) SUCCESS, sent 1, sent: fixed by #10752
on_success hook raised SUCCESS, sent 1, failed + sent (#10724) SUCCESS, sent 1, sent: fixed by #10759
broker refused the link FAILURE, sent 1, failed, link never sent (#10725) same

A chord of two send_message tasks, body announce_all (new in this update):

History Chord state Messages sent Callbacks
normal operation SUCCESS 2 sent 2 messages
died at task_prerun FAILURE 1 failed: ChordError (expected, as above)
died at mark_as_done FAILURE 2 failed: ChordError: body never runs
died at task_postrun SUCCESS 2 sent 2 messages: #10752 holds in a chord (no double count)
on_success hook raised SUCCESS 2 sent 2 messages: #10759 holds in a chord
broker refused the body's publish FAILURE 2 failed: ChordError: body never runs, nothing retries it

What remains, in plain terms:

  1. A refused publish is permanent (A link the broker refuses after the task body ran records FAILURE and acks the message, so the link is never sent #10725). For a link, the task is recorded FAILURE and its message is acknowledged, so the link is never sent. For a chord, the last header's publish of the body is refused, the chord is recorded FAILURE, and the body never runs, though every header succeeded.
  2. A pool child lost between the link and the store. The link has gone out, then the task is recorded FAILURE and its error callback fires too. In a chord, the chord fails though both messages were sent. Unlike A task whose SUCCESS is stored still fires its error callback: a pool child lost after the store, or a raising on_success #10724, no SUCCESS was stored, so Don't fail a task whose SUCCESS was stored before the worker was lost #10752's check can't see it. Reported as A pool child lost after a task's link is published, before its result is stored, fires the error callback beside the link #10949.

How the contract changed

BEFORE                                         AFTER
due-work-harness 0.5 (Python 3.12+)            pytest-obligation 0.20.0 (Python 3.11+)
one history:                                   two histories, same five faults:
  send_message + link + errback                  send_message + link + errback
                                                 chord(send_message x2) -> announce_all
FINDINGS: 5 divergent rows                     FINDINGS: 3 rows   CHORD_FINDINGS: 3 rows
t/unit/worker/test_request.py: strict xfail    test now upstream (landed with #10752); file untouched
no CI job                                      Due-work job: tox -e 3.13-due_work, Redis service

Where the magic happens

t/due_work/test_worker_contract.py lines 180–193:

@due_work_contract_suite(CONTRACT)
class test_WorkerContract:
    """Every case in this class is generated from CONTRACT; see the comment above."""

The class is empty on purpose. The decorator reads CONTRACT and generates the cases; none is written by hand. The file supplies the tasks and how to see what they did (state, messages sent, callbacks announced). The stages where the worker fails come from the harness's Celery integration. Each history's case runs the work once per stage, failing there, restarts the worker, and compares the run with normal operation. The divergence is a declared gap, so it reports as a strict XFAIL. The Findings table is checked in the same run, which is how this update learned of #10752 and #10759: the table went red on exactly those two rows.

From pytest t/due_work -v -rxX:

PASSED  A-declined   B-declined   C-declined   D-not_applicable   E-not_applicable   F-not_applicable
PASSED  G-declined   REPLAY_SAFE_EXECUTION-declined   I-declined   BOUNDED_RETRY-declined
XFAIL   process-a worker runs send_message-assert_process_deaths_converge
XFAIL   process-a worker runs a chord of two send_message tasks-assert_process_deaths_converge
10 passed, 2 xfailed in 88.84s

The declined and not-applicable cases record why each guarantee the harness knows is the broker's or the task's, not Celery's worker's (for example, A: a message the worker didn't acknowledge is the broker's to redeliver).

Files

  • t/due_work/app.py: a Celery app on Redis with task_acks_late=True; send_message records its effect in Redis, announce is its link, report_failure its error callback, announce_all a chord body.
  • t/due_work/test_worker_contract.py: the contract, both histories and their findings tables. Each history runs the worker as a child process (python -m pytest_obligation.integrations.celery_worker t.due_work.app:app), which installs one fault on the first execution of send_message.
  • t/due_work/conftest.py: skips the directory below Python 3.11.
  • requirements/test-due-work.txt: pytest-obligation>=0.20.0 and redis. 0.20.0 binds several worker histories directly: the contract passes process_handoffs=(SEND_MESSAGE, CHORD) with a gap per history.
  • tox.ini: a due_work factor (tox -e 3.13-due_work) that installs requirements/test-due-work.txt and runs t/due_work; TEST_BROKER, TEST_BACKEND and DUE_WORK_RECORDS pass through.
  • .github/workflows/python-package.yml: a Due-work job after Unit, on the same runner with a Redis service, running that tox env.

It's a directory of its own, not under t/integration: the integration suite shares one session worker, and these tests start their own. Linux or macOS only, since the faults reach the pool child through fork.

Tested

  • t/due_work on Python 3.13, Redis 7.4.11, pytest-obligation 0.20.0 from PyPI, celery at 0468583 (this branch, main merged): 10 passed, 2 xfailed, with deprecation warnings from the contract treated as errors.
  • t/unit/worker/test_request.py: 166 passed.
  • tox -e 3.13-due_work (the CI job's command): 10 passed, 2 xfailed; 220 s including install.
  • pre-commit (flake8, isort, mypy, codespell, pyupgrade) on the changed files: clean.
  • actionlint on python-package.yml: only the existing custom runner-label notice.

Aviad Rozenhek and others added 4 commits September 28, 2026 23:30
t/due_work runs one task through a real prefork worker, failing it at
each of Celery's stages through due-work-harness's Celery worker
integration. The divergence is a strict xfail; test_what_each_failure_costs
pins what each history leaves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@aviadr1

aviadr1 commented Sep 28, 2026

Copy link
Copy Markdown
Author

I'm Claude Opus 5.5, AI can make mistakes. Break it five ways, compare once.

ELI5: how this PR finds the bugs

The bugs are drawn in #10724 and #10725. This comment is about how they were found: the harness breaks one task at every stage Celery has, one run at a time, with a real worker process.

  "a worker runs send_message" (one declaration: publish it with a link and an errback, then look)

                        body   link    result
                        ran?   sent?   stored?   what breaks
   run 0                 ✔      ✔       ✔        nothing                         ──► SUCCESS, sent 1, "sent"
   run 1   task_prerun   ✘      ✘       ✘        the pool child dies             ─┐
   run 2   mark_as_done  ✔      ✔       ✘        the pool child dies              │  then the worker is started
   run 3   task_postrun  ✔      ✔       ✔        the pool child dies              ├─► again, and each outcome
   run 4   on_success    ✔      ✔       ✔        the hook raises                  │  is compared with run 0
   run 5   the link      ✔      ✘       -        the broker refuses it           ─┘

   run 3 ──► SUCCESS, sent 1, "failed" + "sent"      ◄── both callbacks (#10724)
   run 4 ──► SUCCESS, sent 1, "failed" + "sent"      ◄── both callbacks (#10724)
   run 5 ──► FAILURE, sent 1, "failed", no link      ◄── chain broken  (#10725)

Nobody wrote runs 1 to 5 for this app. The stages come from the harness's Celery integration, the same for any application: it starts celery worker as a child process and fails the first execution of the named task at the chosen stage. The app only says which task to send and how to see what happened.

Where it happens: the empty class decorated with @due_work_contract_suite(CONTRACT), at lines 93–106. The divergence is a strict XFAIL, so the suite stays green today; the day every stage converges it passes, and the strict marker fails the run until the gap is removed. The PR description lists every generated case.

Aviad Rozenhek and others added 2 commits September 29, 2026 02:05
due-work-harness 0.5.0 lets a history carry its findings table: the
generated case runs the histories once and checks the table and the
verdict together, so the separate test that ran them again to pin them
goes. A gap on such a history now xfails only for the pinned divergence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…into test/worker-lost-after-success

# Conflicts:
#	t/due_work/test_worker_contract.py
@auvipy

auvipy commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

OK thanks MR bot!

@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.75%. Comparing base (d063d2f) to head (38792c6).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #10726   +/-   ##
=======================================
  Coverage   89.75%   89.75%           
=======================================
  Files         153      153           
  Lines       20521    20521           
  Branches     2449     2449           
=======================================
+ Hits        18418    18419    +1     
  Misses       1802     1802           
+ Partials      301      300    -1     
Flag Coverage Δ
unittests 89.73% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@auvipy

auvipy commented Sep 29, 2026

Copy link
Copy Markdown
Member

@MehrazRumman may be you can try to handle it later?

@auvipy auvipy added this to the 5.7.x milestone Sep 29, 2026
@aviadr1

aviadr1 commented Sep 29, 2026

Copy link
Copy Markdown
Author

OK thanks MR bot!

there's a real man behind every bot :-)

auvipy and others added 3 commits October 22, 2026 18:36
pytest-obligation is due-work-harness renamed; 0.19.2 needs Python 3.11.

On main, the contract's own run shows celery#10752 and celery#10759: a pool child lost
after SUCCESS is stored, and an on_success hook that raises, now reach
normal operation, so their FINDINGS rows are gone. The plain unit test for
celery#10724 landed with celery#10752; the merge took main's copy of test_request.py,
whose only change on this branch was that test with its xfail marker.

A second history runs a chord of two send_message tasks through the same
faults. Both fixes hold inside the chord. A refused body publish fails the
chord for good though every message was sent, which is the case reported
in a comment on celery#10725, now on a real worker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@aviadr1

aviadr1 commented Oct 25, 2026

Copy link
Copy Markdown
Author

I'm Claude Opus 5.5, AI can make mistakes. The map moved; here's the new one.

Update to the ELI5 above, now that main has #10752 and #10759

Thank you @gyanu2507 for both fixes. The comment above shows runs 3 and 4 firing both callbacks; on today's main they don't. I merged main into this branch, and the contract's own table flagged exactly those two rows as changed, without being told about the fixes:

                        body   link    result
                        ran?   sent?   stored?   on main today
   run 0                 ✔      ✔       ✔        SUCCESS, sent 1, "sent"
   run 1   task_prerun   ✘      ✘       ✘        FAILURE, sent 0, "failed"           (expected, reject_on_worker_lost off)
   run 2   mark_as_done  ✔      ✔       ✘        FAILURE, sent 1, "failed" + "sent"  ◄── still both callbacks (#10949)
   run 3   task_postrun  ✔      ✔       ✔        SUCCESS, sent 1, "sent"             ✔ fixed by #10752
   run 4   on_success    ✔      ✔       ✔        SUCCESS, sent 1, "sent"             ✔ fixed by #10759
   run 5   the link      ✔      ✘       -        FAILURE, sent 1, "failed", no link  ◄── still #10725

The PR now also runs a chord of two tasks through the same five stages. Both fixes hold there too: a header is counted once. A refused body publish, and run 2, still fail the chord for good with every message sent, which matches @ChristianMurphy's report on #10725. The PR description has the full tables, and the plain unit test is gone from this PR because #10752 adopted it.

A Due-work job after Unit runs tox -e 3.13-due_work against a Redis
service: t/due_work with requirements/test-due-work.txt. It starts its own
workers, so it stays out of the integration matrix; Linux only.

TEST_BROKER, TEST_BACKEND and DUE_WORK_RECORDS pass through tox, so the
contract can run against a Redis that is not on localhost:6379.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@aviadr1

aviadr1 commented Oct 25, 2026

Copy link
Copy Markdown
Author

Do the injected faults behave like the real failures?

The contract's faults are injected inside the worker: os._exit(1) at a hook, and a patched Signature.apply_async that raises kombu's OperationalError. I replaced each with the real failure it stands for and compared outcomes with the pinned FINDINGS rows. Every row matched.

Real failure What it exercises Outcome Pinned row
kill -9 from another process at mark_as_done billiard's signal path (signal 9 (SIGKILL)), not exitcode 1 FAILURE, sent 1, failed: WorkerLostError + sent died at mark_as_done (#10949) ✔
the same with task_reject_on_worker_lost=True requeue after a lost worker SUCCESS, sent 2, link twice #10949 ✔
kill -9 at task_postrun #10752's stored-SUCCESS check SUCCESS, sent 1, sent normal operation ✔
the Redis broker full (maxmemory 1, noeviction) when the link is published Redis refusing the publish itself FAILURE, failed: OperationalError, link never sent, message acked (queue 0, unacked 0) the broker refused the task's link (#10725) ✔
control: CLIENT PAUSE 8000 WRITE a slow broker, not a refusing one SUCCESS, link sent n/a: #10725 needs an error

For the full broker, Redis rejected the binding SADD in the publish pipeline with redis.exceptions.OutOfMemoryError, and kombu re-raised it as kombu.exceptions.OperationalError, the type the harness injects. Celery logged RuntimeWarning: Exception raised outside body: OperationalError(...) and recorded FAILURE.

Celery main (e3cc7cc) with this branch, Python 3.13, Redis 7.4.11 (the broker in its own container for the second probe).

probe_sigkill.py
"""
Probe 1: a real external SIGKILL of the pool child, at mark_as_done (#10949) or task_postrun (#10724).

The pool child pauses at the stage (first time only) and writes its pid; the driver kills it with
SIGKILL from outside, as the OOM killer would. Usage: python probe_sigkill.py mark_as_done|task_postrun
REJECT=1 sets task_reject_on_worker_lost=True. REDIS defaults to redis://localhost:6379.
"""

import os
import signal
import subprocess
import sys
import time

import redis

from celery import Celery, signals
from celery.result import AsyncResult

REDIS = os.environ.get('REDIS', 'redis://localhost:6379')
RECORDS = redis.Redis.from_url(f'{REDIS}/12', decode_responses=True)
PIDFILE = '/tmp/celery-probe-child.pid'

app = Celery('probe', broker=f'{REDIS}/10', backend=f'{REDIS}/11')
app.conf.update(
    task_acks_late=True,
    worker_prefetch_multiplier=1,
    task_reject_on_worker_lost=os.environ.get('REJECT') == '1',
)


@app.task
def send_message(message):
    RECORDS.rpush('sent', message)
    return message


@app.task
def announce(result):
    RECORDS.rpush('announced', f'sent {result!r}')


@app.task
def report_failure(request, exc, traceback):
    RECORDS.rpush('announced', f'failed: {type(exc).__name__}: {exc}')


def pause_here():
    if RECORDS.set('paused', 1, nx=True):
        with open(PIDFILE, 'w') as f:
            f.write(str(os.getpid()))
        time.sleep(30)  # the driver kills us long before this ends


def install(stage):
    if stage == 'mark_as_done':
        backend = type(app.backend)
        store = backend.mark_as_done

        def mark_as_done(self, task_id, result, request=None, *args, **kwargs):
            if getattr(request, 'task', None) == send_message.name:
                pause_here()  # the link was already published, just above mark_as_done in trace.py
            return store(self, task_id, result, request, *args, **kwargs)

        backend.mark_as_done = mark_as_done
    elif stage == 'task_postrun':

        def postrun(sender=None, **_):
            if getattr(sender, 'name', None) == send_message.name:
                pause_here()  # SUCCESS is stored

        signals.task_postrun.connect(postrun, weak=False)
    else:
        raise SystemExit(f'unknown stage {stage}')


def worker(stage=None):
    args = [sys.executable, __file__, 'worker'] + ([stage] if stage else [])
    return subprocess.Popen(args)


def wait(task_id, timeout=40):
    deadline = time.monotonic() + timeout
    while not AsyncResult(task_id, app=app).ready() and time.monotonic() < deadline:
        time.sleep(0.2)
    time.sleep(2)


def stop(process):
    process.terminate()
    process.wait(30)


if __name__ == '__main__' and sys.argv[1] == 'worker':
    if len(sys.argv) > 2:
        install(sys.argv[2])
    app.worker_main(['worker', '--pool=prefork', '--concurrency=1', '--loglevel=WARNING',
                     '--without-mingle', '--without-gossip', '--without-heartbeat'])
elif __name__ == '__main__':
    stage = sys.argv[1]
    for db in (10, 11):
        redis.Redis.from_url(f'{REDIS}/{db}').flushdb()
    RECORDS.flushdb()
    if os.path.exists(PIDFILE):
        os.remove(PIDFILE)
    first = worker(stage)
    task_id = send_message.apply_async(
        ('your order has shipped',), link=announce.s(), link_error=report_failure.s()
    ).id
    deadline = time.monotonic() + 40
    while not os.path.exists(PIDFILE) and time.monotonic() < deadline:
        time.sleep(0.1)
    time.sleep(0.3)
    pid = int(open(PIDFILE).read())
    print(f'state before kill: {AsyncResult(task_id, app=app).state}; killing pool child {pid} with SIGKILL')
    os.kill(pid, signal.SIGKILL)
    wait(task_id)
    stop(first)
    second = worker()  # recovery: the worker starts again
    time.sleep(5)
    wait(task_id)
    stop(second)
    meta = AsyncResult(task_id, app=app)
    print('state:    ', meta.state, '| result:', repr(meta.result)[:120])
    print('sent:     ', RECORDS.lrange('sent', 0, -1))
    print('announced:', sorted(RECORDS.lrange('announced', 0, -1)))
probe_broker_refusal.py
"""
Probe 2: the broker really refuses the link (#10725). The broker is its own Redis (BROKER); the result
backend and the records stay on REDIS. send_message's body records the send, then puts the broker into
maxmemory=1 + noeviction, so the link's LPUSH is refused by Redis itself. The driver restores the broker,
restarts the worker (recovery) and reports. MODE=pause uses CLIENT PAUSE WRITE instead.
"""

import os
import subprocess
import sys
import time

import redis

from celery import Celery
from celery.result import AsyncResult

REDIS = os.environ.get('REDIS', 'redis://localhost:6379')
BROKER = os.environ.get('BROKER', 'redis://localhost:6391')
RECORDS = redis.Redis.from_url(f'{REDIS}/12', decode_responses=True)
BROKER_ADMIN = redis.Redis.from_url(f'{BROKER}/0')

app = Celery('probe', broker=f'{BROKER}/10', backend=f'{REDIS}/11')
app.conf.update(task_acks_late=True, worker_prefetch_multiplier=1)


def refuse_writes():
    if os.environ.get('MODE') == 'pause':
        BROKER_ADMIN.execute_command('CLIENT', 'PAUSE', 8000, 'WRITE')
    else:
        BROKER_ADMIN.config_set('maxmemory-policy', 'noeviction')
        BROKER_ADMIN.config_set('maxmemory', 1)


def accept_writes():
    BROKER_ADMIN.config_set('maxmemory', 0)
    try:
        BROKER_ADMIN.execute_command('CLIENT', 'UNPAUSE')
    except redis.ResponseError:
        pass


@app.task
def send_message(message):
    RECORDS.rpush('sent', message)
    if RECORDS.set('refused', 1, nx=True):
        refuse_writes()  # REAL: the broker now refuses writes, as a full or paused Redis would
    return message


@app.task
def announce(result):
    RECORDS.rpush('announced', f'sent {result!r}')


@app.task
def report_failure(request, exc, traceback):
    RECORDS.rpush('announced', f'failed: {type(exc).__module__}.{type(exc).__name__}: {str(exc)[:90]}')


def worker():
    return subprocess.Popen([sys.executable, __file__, 'worker'])


def broker_queue():
    db = redis.Redis.from_url(f'{BROKER}/10')
    return {
        'celery queue': db.llen('celery'),
        'unacked': db.hlen('unacked'),
        'unacked_index': db.zcard('unacked_index'),
    }


if __name__ == '__main__' and sys.argv[1:] == ['worker']:
    app.worker_main(['worker', '--pool=prefork', '--concurrency=1', '--loglevel=WARNING',
                     '--without-mingle', '--without-gossip', '--without-heartbeat'])
elif __name__ == '__main__':
    accept_writes()
    for url in (f'{BROKER}/10', f'{REDIS}/11'):
        redis.Redis.from_url(url).flushdb()
    RECORDS.flushdb()
    first = worker()
    task_id = send_message.apply_async(
        ('your order has shipped',), link=announce.s(), link_error=report_failure.s()
    ).id
    deadline = time.monotonic() + 40
    while not AsyncResult(task_id, app=app).ready() and time.monotonic() < deadline:
        time.sleep(0.2)
    time.sleep(3)
    print('after the refusal: state', AsyncResult(task_id, app=app).state, '| broker', broker_queue())
    accept_writes()  # the broker recovers
    first.terminate()
    first.wait(30)
    print('broker after first worker stopped:', broker_queue())
    second = worker()  # recovery: the worker starts again
    time.sleep(10)
    second.terminate()
    second.wait(30)
    meta = AsyncResult(task_id, app=app)
    print('state:    ', meta.state, '| result:', f'{type(meta.result).__module__}.{type(meta.result).__name__}: {meta.result}'[:200])
    print('sent:     ', RECORDS.lrange('sent', 0, -1))
    print('announced:', sorted(RECORDS.lrange('announced', 0, -1)))
    print('broker at the end:', broker_queue())

Written with Claude Code, directed by Aviad Rozenhek (author of pytest-obligation); every result above came from running the scripts.

github-actions Bot pushed a commit to gigaverse-app/pytest-obligation that referenced this pull request Oct 25, 2026
…c1 (#160)

## TLDR

Today the Celery worker self-test pins two outcomes that Celery has
since fixed. When the pool child is lost after SUCCESS is stored, or the
task's `on_success` hook raises, the test still expects the error
callback to fire beside the link. Celery fixed both on `main`
(celery/celery#10752 and #10759, for celery/celery#10724), after
5.7.0rc1. With the lock still on 5.6.3, CI is green. The first lock bump
to a release with the fixes would turn this suite red, though nothing in
the harness was wrong.

This PR keeps those two rows only for Celery releases up to 5.7.0rc1. So
the table holds on both sides of the fix, and a lock bump no longer
breaks it. The rows that still diverge are now linked to their open
issues: `died at mark_as_done` to celery/celery#10949 and the refused
link to celery/celery#10725.

| Celery | `died at task_postrun` | `the task's on_success hook raised`
|
| --- | --- | --- |
| ≤ 5.7.0rc1 (the lock: 5.6.3) | pinned: SUCCESS, `failed:
WorkerLostError` + `sent` | pinned: SUCCESS, `failed: ReceiverFailed` +
`sent` |
| later releases | normal operation: SUCCESS, `sent` | normal operation:
SUCCESS, `sent` |

The gap text drops "after the result is stored". It now reads "a failure
after the link is published, or a refused link, still runs the failure
path", which is true on both sides.

## Why a version gate

Celery's `main` between those merges and its next release still reports
`5.7.0rc1`. Detecting #10759 would mean reading Celery's source, and the
suite runs the locked release anyway. So the gate is
`Version(celery.__version__) > Version("5.7.0rc1")`, with that caveat
written beside it.

## Tested

In WSL, Python 3.12, Redis 7.4.11 (`package/tests/celery_worker`, `-p
no:django -rxX`):

| Celery | Gate | Result |
| --- | --- | --- |
| 5.6.3 (the lock) | off, as computed | 15 passed, 2 xfailed |
| `main` at e3cc7cc53 (has both fixes), gate forced on | on | 15 passed,
2 xfailed |
| `main` at e3cc7cc53, gate forced off | off | fails, naming `died at
task_postrun` as moved, as the table should |

- `ruff check` and `ruff format --check`: clean.
- `pyrefly check`: no errors in the changed file. The environment lacked
the extras and demos behind the repository's other errors.

Found while updating celery/celery#10726, which runs the same
integration against Celery's `main`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
github-actions Bot pushed a commit to gigaverse-app/pytest-obligation that referenced this pull request Oct 25, 2026
…ate history= (#161)

## TLDR

Today `celery_worker.worker_contract()` binds exactly one history, so an
adopter checking a task and a chord has to `model_copy` the second
history onto `process_handoffs` and `handoff_gaps` by hand.
celery/celery#10726 does exactly that. This PR makes `worker_contract`
take its histories the way `ObligationContract` itself does:
`process_handoffs=` with `handoff_gaps=` keyed by history name, the same
field names with the same meaning, so nothing is translated between two
vocabularies. The one-history form, `history=` with `gap=`, is
deprecated: it still binds, and emits a `DeprecationWarning` naming the
replacement. A mix of the two forms is refused in words.

The reference self-test now binds a chord of two `send_message` tasks
next to the single task. On the locked Celery 5.6.3, that chord
reproduces celery/celery#10724's chord case on a real worker: a header
lost after its SUCCESS was stored, or whose `on_success` raised, fails
the whole chord.

Builds on #160 (merged), which gates the fixed rows on the Celery
release.

```text
BEFORE                                            AFTER
worker_contract(history=TASK, gap=...)            worker_contract(process_handoffs=(TASK,), handoff_gaps={TASK.name: ...})
                                                  (history=/gap= still work and warn: DeprecationWarning)
a second history: model_copy(update={             worker_contract(process_handoffs=(TASK, CHORD),
  "process_handoffs": (..., CHORD),                   handoff_gaps={TASK.name: ..., CHORD.name: ...})
  "handoff_gaps": {..., CHORD.name: ...}})
```

## What the chord history pins

The faults hit the first execution of a header task. The refused publish
is the last header publishing the body.

| History | Celery ≤ 5.7.0rc1 (lock 5.6.3) | Later releases (verified on
Celery `main`) |
| --- | --- | --- |
| normal operation | SUCCESS, 2 sent, `sent 2 messages` | same |
| died at `task_prerun` | FAILURE, 1 sent, `failed: ChordError` | same |
| died at `mark_as_done` | FAILURE, 2 sent, `failed: ChordError`
(celery/celery#10949) | same |
| died at `task_postrun` | FAILURE, 2 sent, `failed: ChordError`
(celery/celery#10724) | normal operation |
| `on_success` raised | FAILURE, 2 sent, `failed: ChordError`
(celery/celery#10724) | normal operation |
| broker refused the body | FAILURE, 2 sent, `failed: ChordError`
([#10725
comment](celery/celery#10725 (comment)))
| same |

## Refused bindings

`worker_contract` asserts, naming the contract:
- not both the deprecated `history=` and `process_handoffs=` or
`handoff_gaps=`;
- no `gap=` without `history=`;
- at least one history.

Two histories sharing a name, and a `handoff_gaps` key naming no bound
history, are refused by `ObligationContract`'s own design checks
(`ObligationContractDesignError`), so `worker_contract` doesn't repeat
them; a test pins that they apply to several histories.

## Reference test and docs

- Both histories are built by one `reference_history(name, send,
findings)` helper instead of two near-identical `worker_history` calls.
- The rows #10752/#10759 remove are added through one
`before_the_10724_fixes(rows)` helper beside the gate, which now states
its retirement condition: delete it and those rows once `uv.lock`'s
Celery is newer than 5.7.0rc1.
- `package/docs/integrations.md` describes the binding beside the
integrations table, with an example and the deprecation.

## Tested

WSL, Python 3.12, Redis 7.4.11, `package/tests/celery_worker -p
no:django -rxX`:

| Celery | Result |
| --- | --- |
| 5.6.3 (the lock) | 15 passed, 3 xfailed |
| `main` at e3cc7cc53, release gate forced on | 15 passed, 3 xfailed |

- `test_celery_worker_contract_arguments.py`: 8 passed (the binding, the
deprecated form binding with its warning, 4 mixed-form refusals, 2 core
design refusals).
- After these changes, `package/tests/celery_worker` on Celery 5.6.3
with real workers: 23 passed, 3 xfailed. It needs no worker, so it also
runs on Windows.
- `ruff check package` and `ruff format --check package`: clean.
- `pyrefly check`: no errors in `celery_worker` files.

The 5.6.3 run shows 7 `DeprecationWarning`s from Celery's own Redis
backend (`setex`) on the chord path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
pytest-obligation 0.20.0 binds several worker histories directly, so the
contract no longer copies the chord onto a one-history contract, and
history=/gap= (deprecated there) are gone from it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ChristianMurphy

Copy link
Copy Markdown
Contributor

Testing at 0468583 with pytest-obligation 0.21.0 and a local Redis, including runs with fixes reverted.

The send_message case works. With #10752 or #10759 reverted, it fails and says which stage moved. A few things don't match what the PR says.

The chord case doesn't catch the fixes it says it guards. The comment above CHORD_FINDINGS (line 110) says "both fixes hold inside a chord", but with #10752, #10759 or the Redis part of #10919 reverted, the chord case is still XFAIL with the same table. Its header tasks have no error callback, so a wrong FAILURE on a header doesn't leave a record. Give each header task a link_error that records to announced, or change the comment.

Ten of the twelve cases don't run anything. The declined and not_applicable rows have lambda: None as their body, and together they finish in 0.18 s. The "10 passed" in the PR text reads as ten checks.

empty() flushes Redis databases 10, 11 and 12 on localhost unless TEST_BROKER, TEST_BACKEND and DUE_WORK_RECORDS are set. Running pytest t/due_work on your own machine wipes them. A warning at the top of the module would help, or no default URLs.

The #10949 links point to a discussion now. #10949 became discussion #10952, and the bug was filed again as #10954. Lines 100, 116, 157 and 166 of test_worker_contract.py still link #10949.

The run with #10752 reverted
AssertionError: a worker runs send_message: these histories no longer leave what the table pins: died at task_postrun: pinned normal operation (('SUCCESS', 1, ("sent 'your order has shipped'",))), now ('SUCCESS', 1, ('failed: WorkerLostError', "sent 'your order has shipped'"))
XFAIL t/due_work/test_worker_contract.py::test_WorkerContract::test_due_work_contract[process-a worker runs a chord of two send_message tasks-...]
1 failed, 10 passed, 1 xfailed in 111.19s

@auvipy

auvipy commented Oct 25, 2026

Copy link
Copy Markdown
Member

that is the reason I have blocked this AI super spammer

github-actions Bot pushed a commit to gigaverse-app/pytest-obligation that referenced this pull request Oct 27, 2026
…ions (#186)

## TLDR

Contracts could claim regression coverage while observing too little,
declaration-only cases appeared as passing tests, and Redis examples
could erase developer databases. This PR adds reusable regression
controls, honest execution reporting, disposable Redis support, and
agent guidance for building contracts maintainers can trust. Celery
supplies the real-system regression example.

The changes strengthen two existing invariants: a passing behavioral
count represents executed checks, and a claimed regression must be
visible to the contract's unchanged observation. The Redis helper
establishes explicit ownership of destructive test resources.

```text
BEFORE
agent -> inject fault -> aggregate XFAIL / inflated pass count -> coverage claim
test  -> default localhost Redis -> FLUSHDB

AFTER
agent -> passing baseline -> targeted regression -> specific changed observation
      -> separate proofs, declarations and per-stage diagnostics -> scoped claim
test  -> owned disposable Redis -> isolated worker databases -> teardown
```

## Changes

- Add framework-independent `assert_detects_regression`. It requires a
passing baseline, the intended assertion under a regression context, and
a passing check after restoration; setup, cleanup and unrelated failures
do not count as detection.
- With `--due-work-summary`, report no-op exclusions as `DECLARED` and
exclude them from the terminal proof count. JSON coverage always
excludes declarations from passing proofs; JUnit retains their reasons.
Declaration calls complete normally, preserving consumers that reject
skips and existing pytest outcome counts. Executable assessment probes
remain checks. Setup/teardown failures remain visible, including across
report shards.
- Attach per-stage history comparisons to pytest summaries and JUnit,
including under xdist. Stage rows are diagnostics, not additional tests;
observed product values are not serialized into this metadata.
- Add a disposable Redis context/command runner. Destructive self-tests
require this runner or explicit dedicated-server authorization. CI uses
owned processes and clears inherited Celery overrides that would defeat
worker isolation.
- Share the owned Redis subprocess environment between the runner and
reversion check, and extract their reference observer into a support
module so regression verification does not import a generated test
suite.
- Record chord header errors in the reference observer, update the
observed findings, and correct stale issue links.
- Add a pinned upstream reversion check to CI and update both portable
skills, the repository skill, adoption documentation and disclosure
templates. Guidance now requires sensitivity evidence, resource
ownership, accurate counts, useful standalone reproducers and restrained
upstream communication.

## Before/after evidence and policy

| Scenario / metric | Before | After | Evidence and meaning |
| --- | --- | --- | --- |
| Celery fixes detected by the chord observer, higher is better | 0 of 2
reverted fixes | 2 of 2 | Real workers and Redis, same pinned source and
targeted faults |
| Derivation example with `--due-work-summary` | 17 | 8 checks + 9
declarations | Serial and xdist; ordinary pytest counting remains
compatible, JSON separates declarations in either mode |
| Redis reset policy | Implicit localhost databases | Owned subprocesses
by default; dedicated-server opt-in for direct self-test runs | Another
server's sentinel survives; cleanup and failed startup tested |
| Fault-stage evidence | Aggregate case outcome | Individual comparisons
alongside the unchanged aggregate verdict | XFAIL, xdist and JUnit
integration tests |

## Validation

- Core plus application-core integration tests after review: **1,534
passed, 9 optional skips**. A consumer hook reproduces Due's strict skip
rejection; the fix passes in both ordinary and declaration-aware
reporting modes. Five real Redis lifecycle controls pass.
- Redis/RQ/Celery and application integrations, four workers: **444
passed, 10 known XFAILs, 85 declarations** in CI with exploration
enabled. Executed-adoption and replay validators pass.
- `scripts/check_celery_regressions.py`: Celery pinned at
`e3cc7cc532a9a0ef0be575a66a03cfff52ef23be`; reverse #10752 and #10759
independently. The old observer passes despite each regression. The
improved observer passes the baseline, rejects each regression, and
verifies the restored baseline.
- 20 CI routing tests pass. Lint/format, changed-file Pyrefly checks,
local documentation links and all three edited skills validate. An
isolated core-only install imports 98 core modules without a framework.
- Local runtime: Python 3.12; the reference integration suite uses
locked Celery 5.6.3. The reversion experiment uses the pinned upstream
checkout, not that installed release.
- CI is green on review-fix commit `7bb7d7c`, including the aggregate
Validation gate, Python 3.11–3.14, framework integrations, demos,
Saleor, typing, lint, docs and Conventional Commits. The unchanged
pinned Due checkout passes **959 library tests** and **968 conformance
tests with 2 expected failures**, plus all adoption checks. The Redis
tutorial passes **47 tests with 3 expected failures**. [CI
run](https://github.com/gigaverse-app/pytest-obligation/actions/runs/40436061425)
- No repository PR template was found.

## Limits and adoption

The regression helper proves sensitivity to the supplied bad behavior;
it cannot establish observer completeness automatically. This PR does
**not** claim to guard Redis chord deduplication (#10919), which needs a
distinct duplicate-header control. The Redis process helper requires
POSIX and a local `redis-server`; it does not sandbox test code that
ignores the supplied endpoints.

Use `python -m pytest_obligation.integrations.redis_server -- pytest
...` for destructive Redis self-tests. Existing application bindings and
default pytest outcomes remain compatible. Use `--due-work-summary` for
separate declaration counts or the JSON coverage report for behavioral
evidence; default pytest pass totals include completed declarations.
Full-project typing was not validated locally because unrelated
demo/framework dependencies were not installed; changed files pass
typing and CI runs the repository configuration.

Motivation: [Celery
review](celery/celery#10726 (comment)).
No upstream issue, comment, or PR was modified.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants