Skip to content

Fix AttributeError when empty group is chained (#9772)#10245

Open
kaiisfree wants to merge 14 commits intocelery:mainfrom
kaiisfree:fix/empty-group-chain-crash
Open

Fix AttributeError when empty group is chained (#9772)#10245
kaiisfree wants to merge 14 commits intocelery:mainfrom
kaiisfree:fix/empty-group-chain-crash

Conversation

@kaiisfree
Copy link
Copy Markdown

Summary

Fixes #9772

chain(group([task1, task2]), group(), group()) crashes with AttributeError: 'NoneType' object has no attribute 'parent' because:

  1. When an empty group() is used as a chord body, chord.freeze() returns None (empty groups are falsy, so the if self.body: check in chord.freeze() skips body freezing)
  2. Back in chain.prepare_steps(), the code then tries to traverse res.parent on the None result at line 1277

Changes

Three defensive fixes in celery/canvas.py, all in the chain resolution path:

  • Skip empty groups in prepare_steps: Empty groups are no-ops and should not participate in chain resolution or be upgraded to chords
  • Null check before parent traversal: After freezing a chord, guard against res being None before traversing node.parent
  • Handle empty results in chain.freeze(): Return None instead of raising IndexError when all steps were skipped

Test plan

🤖 Generated with Claude Code

When an empty group() is part of a chain, the chain resolution crashes
with `AttributeError: 'NoneType' object has no attribute 'parent'`.

Root cause: chord.freeze() returns None when the body is an empty group
(since empty groups are falsy), and prepare_steps then tries to traverse
`res.parent` on the None result.

Fix:
- Skip empty groups in prepare_steps as they are no-ops
- Add null check for res before traversing parent chain after chord freeze
- Handle empty results list in chain.freeze()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@auvipy auvipy requested review from auvipy and Copilot April 6, 2026 07:22
@auvipy auvipy added this to the 5.7.0 milestone Apr 6, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a crash in Celery canvas chain resolution when empty group() signatures appear in a chain (notably when they would otherwise be upgraded into a chord), addressing issue #9772.

Changes:

  • Add regression tests ensuring chains containing empty groups no longer raise AttributeError.
  • Update chain.prepare_steps() to skip empty groups and to guard parent traversal when a frozen chord returns None.
  • Update chain.freeze() to return None (instead of raising IndexError) when no results are produced.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
t/unit/tasks/test_canvas.py Adds regression tests for chaining empty groups without crashing.
celery/canvas.py Adjusts chain freezing/step preparation to defensively handle empty groups and None results.

Comment thread celery/canvas.py Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.29%. Comparing base (1a39f8c) to head (f20a7be).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #10245      +/-   ##
==========================================
+ Coverage   88.27%   88.29%   +0.01%     
==========================================
  Files         153      153              
  Lines       19663    19694      +31     
  Branches     2266     2277      +11     
==========================================
+ Hits        17358    17388      +30     
- Misses       2005     2006       +1     
  Partials      300      300              
Flag Coverage Δ
unittests 88.26% <100.00%> (+0.01%) ⬆️

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

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

When an empty group appeared as the first step in a chain, the
is_first_task flag was consumed by the skipped empty group, so the
next real task never received the chain's partial args/kwargs.
For example, chain(group(), add.s())(2, 2) would run add() without
arguments.

Fix: introduce a first_task_args_applied flag to track whether the
chain's partial args have been applied, and move the empty group
skip before the args application logic. This ensures the first
non-empty step always receives the chain's partial args.

Also adds a focused regression test for this edge case.

Addresses review feedback on PR celery#10245.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread celery/canvas.py Outdated
The previous fix (88016f7) introduced `first_task_args_applied` to track
whether the chain's partial args/kwargs had been applied.  However,
because `prepare_steps` iterates in reverse (popping from the right),
the flag caused args to be applied to the *last* task in the original
chain rather than the *first* -- breaking `chain(group(), a, b)(x)`
semantics where `x` should be prepended to `a`, not `b`.

Fix: strip leading empty groups from the left of the deque *before*
the reverse-iteration loop so the existing `is_first_task = not steps`
check works correctly.  Empty groups elsewhere in the chain are still
skipped inline via `continue` as before.

Add six regression tests covering:
- leading empty group with args passthrough
- multiple leading empty groups
- empty group in the middle of a chain
- trailing empty group
- verification that args reach the first real task, not the last
- chain consisting of only empty groups

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kaiisfree
Copy link
Copy Markdown
Author

@auvipy Cross-checked both Copilot concerns. The second one was valid — good catch by Copilot.

Root cause: first_task_args_applied tracked the first task in the reverse iteration (i.e., the chain's last task), not the first-in-order task. This would misapply partial args for chain(group(), add.s(x), add.s(y))(z)z would go to add.s(y) instead of add.s(x).

Fix (commit 08e5adc): Instead of the first_task_args_applied flag, we now pre-strip leading empty groups from the deque before the reverse-iteration loop begins. This restores the original is_first_task = not steps logic, which correctly identifies the chain's first-in-order task. Empty groups in middle/trailing positions are still handled by the existing inline continue.

6 regression tests added covering:

  • Leading empty group with partial args passthrough
  • Multiple leading empty groups
  • Empty group in middle of chain
  • Trailing empty group
  • Verification that args reach correct task (first real, not last)
  • Chain of only empty groups

All 205 tests pass.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment thread celery/canvas.py Outdated
Comment thread celery/canvas.py
Comment on lines +1219 to +1222
if isinstance(task, _chain):
# splice (unroll) the chain
steps_extend(task.tasks)
continue
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

When splicing nested _chain instances (steps_extend(task.tasks)), leading empty group() steps inside the nested chain won't be removed by the earlier “strip leading empty groups” pre-pass (since those groups are introduced later). This can still drop the chain's partial args/kwargs in cases like _chain(_chain(group(), add.s()), ...). Could we add a regression test for nested-chain splicing with leading empty groups, and consider stripping no-op groups after splicing (or using a dedicated "first real task" flag) to preserve arg forwarding?

Copilot generated this review using guidance from repository custom instructions.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@kaiisfree please cross check this and other relevant suggestions

Comment on lines +879 to +883
"""When an empty group precedes a real task, partial args must
be forwarded to the first non-empty step.

Regression test for PR #10245 review feedback.
"""
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

Several new test docstrings reference "PR #10245" even though this PR is about issue #9772 / PR #9772. Can we update these references to avoid confusion when tracking regressions later?

Copilot uses AI. Check for mistakes.
…strings

- Fix args not forwarded to first real task when a nested chain contains
  leading empty groups (chain splice + empty group skip caused `not steps`
  to misfire). Added post-loop fallback that applies partial args to the
  logically first task when the in-loop heuristic missed it.
- Fix flake8 E303 (extra blank line before test_group class).
- Fix test docstrings to reference issue celery#9772 instead of PR celery#10245.
- Add test_nested_chain_with_leading_empty_group regression test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: kaiisfree <kaiisfree@users.noreply.github.com>
@kaiisfree
Copy link
Copy Markdown
Author

Thanks for the thorough review @auvipy! I've pushed a new commit addressing each concern. Here's the breakdown:

Concern 1: Args/kwargs forwarding when leading empty groups are skipped

This is handled correctly. The pre-strip in prepare_steps removes leading empty groups from the deque before the reverse iteration begins, so is_first_task (not steps) fires for the correct task — the first real task after the empty groups. Tests test_chain_empty_group_first_passes_args, test_chain_empty_group_first_args_go_to_first_real_task, and test_chain_empty_group_args_reach_correct_task all verify this.

Concern 2: should_apply_first_args flag logic

Verified — the reverse walk applies args correctly. is_first_task = not steps is True for the last item popped from the deque, which is the first logical task. The pre-strip ensures the deque starts with a real task. Multiple regression tests confirm args go to the first real task.

Concern 3: _chain.freeze() returning None

freeze() returns results[0] if results else None. This only returns None for a chain consisting entirely of empty groups (no real work). This is the correct semantic — there is no AsyncResult to return when there are no tasks. test_chain_only_empty_groups covers this path.

Concern 4: Nested chain splicing with leading empty groups ✅ Valid concern, fixed!

When _chain(_chain(group(), add.s()), ...) is processed, the inner chain gets spliced into the deque. The inner empty group() becomes a regular step. Added a post-loop fallback with an applied_first_args tracking flag — if the loop finishes without applying args to any task, it applies them to the logically first task. Added test_nested_chain_with_leading_empty_group to cover this case.

Concern 5: Test docstring references

Fixed — all test docstrings now reference "issue #9772" instead of "PR #10245".

Also fixed the flake8 E303 lint error. All 207 tests pass (206 existing + 1 new).

kai and others added 2 commits April 6, 2026 10:12
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address Copilot review concern: _chain.freeze() now returns an
AsyncResult instead of None when all steps are empty groups,
preserving the return type contract so callers can safely access
.id and .parent.

Also removes the xfail marker from
test_apply_contains_chords_containing_empty_chain since the empty
chain fix makes that test pass now.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kaiisfree
Copy link
Copy Markdown
Author

Cross-check of Copilot's review concerns

I've gone through each concern carefully against the actual code. Here's the analysis:


Concern 1: Empty group() entries cause is_first_task misfire, partial args not applied

Verdict: Already handled — not a real issue.

The code has a two-layer defense:

  1. Pre-pass (lines 1175–1186): Strips all leading empty groups from steps before the main loop, so is_first_task = not steps fires correctly for the first real task.
  2. Fallback (lines 1320–1325): The applied_first_args flag tracks whether args were applied. If not (e.g., due to chain-splicing interactions), the fallback applies them to tasks[-1] which IS the first logical task (list is in reverse order).

Tests test_chain_empty_group_first_passes_args, test_chain_empty_group_first_args_go_to_first_real_task, and test_chain_empty_group_args_reach_correct_task all verify this works correctly.


Concern 2: applied_first_args applies args to wrong task

Verdict: Not valid — Copilot misread the data structure.

The fallback applies args to tasks[-1]. Since tasks is built in reverse order (the loop pops from a deque and appends), tasks[-1] is the first logical task, not the last. This is correct. The existing test test_chain_empty_group_args_reach_correct_task explicitly verifies that args go to the first task and NOT the last.


Concern 3: _chain.freeze() returns None when all steps are empty

Verdict: Valid — fixed in this push.

When prepare_steps() yields no results (chain of only empty groups), freeze() was returning None, breaking the AsyncResult return type contract. Callers that access .id or .parent would get AttributeError.

Fix: freeze() now returns self.app.AsyncResult(_id or uuid()) for the empty case, preserving the return type contract.

Bonus: The existing test_apply_contains_chords_containing_empty_chain was marked @pytest.mark.xfail because it used to raise IndexError on empty chains. Our fix resolves this, so I've removed the xfail marker — it now passes cleanly.

Added test: test_chain_only_empty_groups_freeze_returns_result


Concern 4: Nested _chain with leading empty groups inside won't be stripped by pre-pass

Verdict: Technically true but already handled — not a bug.

Copilot is correct that the pre-pass only strips top-level leading empty groups. A nested _chain(_chain(group(), add.s()), ...) won't have the inner group() stripped by the pre-pass because the outer head is a _chain, not a group.

However, the main loop handles this correctly:

  1. The outer _chain gets spliced (unrolled) via steps_extend(task.tasks)
  2. The inner group() is then encountered as a regular step and skipped by the isinstance(task, group) and not task.tasks check
  3. If is_first_task wasn't True for the right task, the applied_first_args fallback catches it

The existing test test_nested_chain_with_leading_empty_group validates this exact scenario and confirms args are applied correctly.


Concern 5: Test docstrings reference "PR #10245" instead of issue #9772

Verdict: Not valid — already correct.

All test docstrings already reference "issue #9772", not "PR #10245". No change needed.


Summary

Concern Valid? Action
1 — Leading empty groups drop args No Already handled by pre-pass + fallback
2 — Args applied to wrong task No Copilot misread reverse-order list
3 — freeze() returns None Yes Fixed: returns AsyncResult now
4 — Nested chain empty groups Partially Already handled by splice + fallback
5 — PR reference in docstrings No Already references #9772

All 208 canvas tests pass, plus the 1 previously-xfailed test now passes too (209 total).

…ice tests

Concern 1: Add docstring to _chain.freeze() documenting that it returns
a minimal AsyncResult (not None) when all steps are empty groups.

Concern 2: Add regression tests for nested-chain splicing with leading
empty groups — multiple leading empties, inner chain of only empty
groups, and trailing empty groups after splice. The main loop already
handles these correctly via the empty-group skip + applied_first_args
fallback.

Concern 3: Already addressed — all test docstrings reference issue celery#9772.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kaiisfree
Copy link
Copy Markdown
Author

Round 3 cross-check — all 3 Copilot concerns addressed

Concern 1: freeze() returning None — Added a docstring to _chain.freeze() that explicitly documents the empty-chain behavior: when all steps are empty groups (no-ops), freeze() returns a minimal AsyncResult so callers can safely access .id and .parent. The underlying fix was already in place (commit d92e487); this adds the documentation Copilot requested.

Concern 2: Nested chain splicing with leading empty groups — Added 3 new regression tests covering post-splice empty group scenarios:

  • test_nested_chain_multiple_leading_empty_groups_after_splice: inner chain with 2 leading empty groups
  • test_nested_chain_only_empty_groups_spliced: inner chain that is only empty groups
  • test_nested_chain_trailing_empty_group_after_splice: trailing empty group in spliced chain

All pass. The existing code already handles these correctly: when _chain is spliced via steps_extend(task.tasks), the spliced tasks re-enter the main while steps loop where the empty-group continue check (line 1224–1227) skips them, and the applied_first_args fallback (lines 1321–1328) ensures partial args reach the correct first real task. No additional post-splice stripping is needed.

Concern 3: Test docstring references — Already addressed in commit 02f8996. All test docstrings reference "issue #9772" (the underlying issue), not "PR #10245".

All canvas tests pass (211 passed, 1 xfailed).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread celery/canvas.py
Comment on lines +1112 to +1117
if results:
return results[0]
# All steps were empty groups (no-ops); return a minimal
# AsyncResult so callers can safely access .id / .parent.
# (Issue #9772)
return self.app.AsyncResult(_id or uuid())
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

_chain.freeze() now fabricates an AsyncResult when prepare_steps() returns no results. This can create results that will never be fulfilled when the empty chain is embedded in a group/chord header: group._apply_tasks() will attach barriers/callbacks to the frozen result, but chain.apply_async() returns without publishing any tasks for an empty chain. That can yield a GroupResult/chord header that never becomes ready (or chords that never unlock depending on backend). Consider keeping an explicit error for empty chains in these contexts, or teaching group/chord preparation to skip no-op chain signatures instead of producing a pending AsyncResult.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@kai @kaiisfree can you please look into the remaining issues including this?

Comment thread t/unit/tasks/test_canvas.py Outdated
Comment on lines 1328 to 1335
def test_apply_contains_chords_containing_empty_chain(self):
gchild_sig = chain(tuple())
child_count = 24
child_chord = chord((gchild_sig,), self.add.si(0, 0))
group_sig = group((child_chord,) * child_count)
# This is an invalid setup because we can't complete a chord header if
# there are no actual tasks which will run in it. However, the current
# behaviour of an `IndexError` isn't particularly helpful to a user.
# Previously this raised IndexError due to empty chain handling.
# Fixed by Issue #9772 -- empty chains now resolve gracefully.
group_sig.apply_async()
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

This test was previously marked xfail as an invalid canvas setup (chord header contains chain(tuple()), i.e., an empty chain). With the new _chain.freeze() behavior returning a fabricated AsyncResult, apply_async() no longer raises, but the frozen result has no underlying task to ever complete, which can make the resulting GroupResult/chord header permanently pending (or prevent chord unlock depending on backend). Can we either keep asserting an explicit, user-friendly exception here, or add coverage that the chord actually completes (instead of only “does not raise”)?

Copilot generated this review using guidance from repository custom instructions.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

and this? @kaiisfree

@kaiisfree
Copy link
Copy Markdown
Author

Addressing Copilot review concerns

Concern 1: PR number references in test docstrings

Verified — all test docstrings already reference issue #9772, not PR #10245. No changes needed. (You can confirm by searching the test file: grep -n "10245\|PR #" t/unit/tasks/test_canvas.py returns nothing.)

Concern 2: freeze() fabricated AsyncResult for empty chains in group/chord headers

I traced the full code path for chord((chain(tuple()),), body):

  1. chord.run() checks if header.tasks: — the header group has 1 element (chain(tuple())), so it enters the publishing branch, not the empty-header shortcut (body.delay([]))
  2. group._apply_tasks() iterates over the header tasks. For the empty chain:
    • chain.freeze() returns a fabricated AsyncResult (the new code)
    • _chord._descend(sig) returns 0 for empty chains (line 2244–2252) — so chord_size is correctly set to 0
    • chain.apply_async()chain.run() returns None without publishing any tasks
  3. set_chord_size(group_id, 0) is called — chord behavior with size 0 is backend-dependent

This is a pre-existing edge case, not introduced by this PR. The analogous scenario with empty groups (not chains) already has a pre-existing @pytest.mark.xfail test at line 1385 (test_apply_contains_chords_containing_empty_group), with the comment "this is an invalid setup... probably being left incomplete forever".

The prepare_steps stripping handles the common user-facing case: chain(group(), group(), add.s(1,1)) correctly strips the empty groups before they can become chord headers. The fabricated AsyncResult in freeze() is a safety net for edge cases where an empty chain is embedded directly as a chord header element — and in that case, the behavior is no worse than before (previously it would crash).

Concern 3: Removed xfail from test_apply_contains_chords_containing_empty_chain

The removed @xfail from this test (line 1328) is correct. The previous xfail reason was "Invalid canvas setup with bad exception" — the test was marked xfail because it crashed with IndexError, not because the chord-never-unlocking behavior was being tested.

The test now just asserts no exception is raised (group_sig.apply_async() completes without error). It does not assert the chord completes or that the body fires. This is the right scope: verifying crash prevention. The chord-unlocking semantics for degenerate empty headers is a separate concern that already exists (see the still-xfail test_apply_contains_chords_containing_empty_group test).

If we want to address the chord-never-unlocking case for empty chain headers in the future, that would be a separate enhancement — e.g., teaching chord.run() to detect when all header tasks are empty and fall through to the body.delay([]) shortcut. But that's out of scope for this crash-fix PR.

@auvipy
Copy link
Copy Markdown
Member

auvipy commented Apr 6, 2026

@ChickenBenny would you mind trying and reviewing this?

…ontexts

Skip empty chains (no-op) in group._prepared() and group._freeze_unroll()
so they are never included in chord headers or group barriers. Without this,
a fabricated AsyncResult from chain.freeze() would be attached to a barrier
that no worker ever fulfills, causing chords and groups to hang indefinitely.

Issue celery#9772

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kaiisfree
Copy link
Copy Markdown
Author

Round 5: Copilot Concern Analysis & Fix

@auvipy — I've analyzed each Copilot concern and pushed a fix for the one that turned out to be a real bug. Here's the breakdown:


Concern 1 (CRITICAL — canvas.py:1117): Fabricated AsyncResult can cause permanently pending group/chord — REAL BUG, FIXED

This one is valid. Here's the exact code path:

  1. When a chord header contains chain(tuple()) (an empty chain), group._freeze_unroll() (line 1978) processes it as a non-group task — it calls task.freeze() on the chain, which hits the fabricated AsyncResult path at line 1117.
  2. The fabricated result gets included in the chord's frozen results.
  3. group._apply_tasks() then calls sig.apply_async() on the empty chain. chain.run() returns None (line 1077: if results_from_prepare: is falsy, so it falls through without publishing any tasks).
  4. But _apply_tasks already incremented p.size += 1 on the barrier at line 1863.
  5. Result: the barrier has a slot that no worker will ever fulfill — chord hangs permanently.

The same issue exists in group._prepared() (line 1793-1802), which yields (task, result, group_id) tuples for empty chains that will never complete.

Fix (commit 22cf5dd): Skip empty chains in both _prepared() and _freeze_unroll(), matching the existing empty-group-skip logic in prepare_steps():

# In _prepared() and _freeze_unroll():
elif isinstance(task, _chain) and not task.tasks:
    # Skip empty chains -- they are no-ops.  (Issue #9772)
    continue

This means when a chord header contains only empty chains, header.tasks becomes empty after freezing, and chord.run() takes the else branch at line 2322 (body.delay([])) — executing the body immediately instead of hanging.


Concern 2 (CRITICAL — test_canvas.py:1335): Test doesn't verify chord completes — ADDRESSED BY CONCERN 1 FIX

With the Concern 1 fix, the empty chains are now stripped from the chord header during _freeze_unroll(). The chord header becomes effectively empty, so the chord body is executed immediately via body.delay([]). Updated the test docstring to explain the expected behavior. The test itself (calling apply_async() without raising) is still correct — it now works for the right reason (empty header = immediate body execution) rather than the wrong reason (fabricated result attached to barrier).


Concern 3 (MEDIUM — canvas.py:1239): Nested chain splice misses leading empty groups — NOT A REAL ISSUE

The concern says the "strip leading empty groups" pre-pass (lines 1192-1198) won't catch empty groups inside nested chains after splicing. This is correct — the pre-pass only runs once at the top of prepare_steps(). However, this doesn't matter because:

  • When steps_extend(task.tasks) splices a nested chain at line 1238, the nested chain's tasks are added to the steps deque.
  • Each task is then processed in subsequent iterations of the while steps: loop.
  • The empty group check at lines 1231-1234 (if isinstance(task, group) and not task.tasks: continue) runs every iteration, not just for leading groups.
  • So empty groups from nested chains ARE caught — just by the in-loop check rather than the pre-pass.

The existing tests test_nested_chain_with_leading_empty_group and test_nested_chain_multiple_leading_empty_groups_after_splice already cover this case and pass.


Concern 4 (LOW — test_canvas.py:883): Docstrings reference PR instead of issue — FALSE ALARM

All docstrings consistently reference "issue #9772", not "PR #10245". Checked every occurrence — line 882, 896, 909, 922, 934, 946, 964, 975, 988, 1008, 1025, 1042, 1334 — all say issue #9772. No fix needed.


Concern 5 (from earlier round): freeze() return type — ALREADY ADDRESSED

_chain.freeze() (lines 1112-1117) always returns an AsyncResult: either from results[0] when there are real steps, or a fabricated one via self.app.AsyncResult(_id or uuid()) for all-empty chains. It never returns None. The return type is AsyncResult, not Optional[AsyncResult].


Summary

Concern Verdict Action
1 — Fabricated result hangs chord Real bug Fixed in 22cf5dd
2 — Test coverage gap Real Addressed by Concern 1 fix + updated docstring
3 — Nested chain splice Not a real issue In-loop check already handles it
4 — Wrong references False alarm All refs are correct
5 — freeze() return type Already addressed Returns AsyncResult, never None

All 211 canvas tests pass (1 xfail expected).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

Comment thread celery/canvas.py Outdated
Comment thread celery/canvas.py
Comment on lines 1796 to +1801
task.tasks, partial_args, group_id, root_id, app,
)
yield from unroll
elif isinstance(task, _chain) and not task.tasks:
# Skip empty chains -- they are no-ops. (Issue #9772)
continue
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The new “skip empty chains” logic in group._prepared() only skips _chain instances with not task.tasks (e.g., chain(tuple())), but does not skip chains that become no-ops after prepare_steps() drops empty group() steps (e.g., chain(group(), group())). Those chains will still be frozen (producing a fabricated AsyncResult) and included in group/chord headers, but chain.apply_async() will publish no tasks and never fulfill the frozen result, potentially causing GroupResult/chords to remain pending forever. Can we broaden the skip condition to detect “effectively empty” chains (e.g., strip/inspect chain members and skip if they contribute zero tasks), similar to how _chord._descend() treats empty chains as size 0?

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

and this @kaiisfree

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch from Copilot -- this was a real gap. A chain like _chain(group(), group()) has a non-empty .tasks list, so not task.tasks evaluates to False and the chain slips through to the else branch where it gets frozen as a regular task, producing a fabricated result that never completes.

Fixed in 9a81645: I added a _chain_effectively_empty() helper (canvas.py L1525-1541) that checks whether a chain's members are all empty groups (after maybe_unroll_group). Both _prepared() and _freeze_unroll() now use this helper instead of the simple not task.tasks check.

Added test_group_prepared_skips_effectively_empty_chain to verify that _chain(group(), group()) is correctly skipped in group headers. All 215 canvas tests pass.

Comment thread celery/canvas.py Outdated
Comment on lines +1985 to +1988
elif isinstance(task, _chain) and not task.tasks:
# Skip empty chains -- they are no-ops and would produce
# fabricated results that never complete. (Issue #9772)
continue
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

Same issue as in group._prepared(): _freeze_unroll() only skips _chain when not task.tasks, but a chain containing only empty groups (chain(group(), group())) will still be frozen and contribute a fabricated result that never completes. This can make empty/no-op chains inside group/chord headers cause hangs. Consider skipping chains that are effectively empty after accounting for empty-group stripping (or otherwise ensuring such chains don’t produce pending results).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@kai please cross check this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same fix applied here. The _chain_effectively_empty() helper now covers both _prepared() and _freeze_unroll() -- chains like _chain(group(), group()) whose .tasks are all empty groups are now correctly detected and skipped in both paths. See 9a81645.

Comment thread t/unit/tasks/test_canvas.py Outdated
Comment thread celery/canvas.py
kaiisfree and others added 5 commits April 6, 2026 13:04
Add 3 targeted tests to achieve 100% patch coverage:

- test_chain_leading_empty_group_as_dict_strip: exercises the from_dict
  conversion path when a serialized (dict) empty group is the leading
  element in prepare_steps() (covers lines 1190-1191, branch 1189->1190)

- test_chain_freeze_nested_chain_leading_empty_group_no_clone: exercises
  the applied_first_args fallback in the clone=False path used by
  freeze(), where a nested chain with a leading empty group prevents
  is_first_task from firing during the loop (covers line 1337, branch
  1334->1337)

- test_group_prepared_skips_empty_chain: exercises the empty chain skip
  in group._prepared() when an empty chain appears as a group member
  (covers line 1801, branch 1799->1801)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pre-commit.ci was failing because of two occurrences of double blank
lines inside a class body (E303 too many blank lines).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ll()

Chains whose tasks are all empty groups (e.g., _chain(group(), group()))
were not caught by the `not task.tasks` check because their .tasks list
is non-empty. Add _chain_effectively_empty() helper that inspects chain
members after unrolling single-member groups, and use it in both
_prepared() and _freeze_unroll() to skip chains that contribute zero
real tasks. Also strengthen test_apply_contains_chords_containing_empty_chain
to assert that no header tasks are applied and add a dedicated test for
the effectively-empty chain scenario.

Addresses Copilot review feedback on celery#10245.  (Issue celery#9772)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@auvipy auvipy left a comment

Choose a reason for hiding this comment

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

thanks for the new commits. we got a new PR merged to fix an old canvas issue. so there are some new merge conflicts now. please care fully fix the merge conflicts @kai @kaiisfree

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.

chain(group([task1, task2]), group(), group())() throws AttributeError: 'NoneType' object has no attribute 'parent'

4 participants