Fix AttributeError when empty group is chained (#9772)#10245
Fix AttributeError when empty group is chained (#9772)#10245kaiisfree wants to merge 14 commits intocelery:mainfrom
Conversation
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>
There was a problem hiding this comment.
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 returnsNone. - Update
chain.freeze()to returnNone(instead of raisingIndexError) 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. |
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
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>
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>
|
@auvipy Cross-checked both Copilot concerns. The second one was valid — good catch by Copilot. Root cause: Fix (commit 08e5adc): Instead of the 6 regression tests added covering:
All 205 tests pass. |
| if isinstance(task, _chain): | ||
| # splice (unroll) the chain | ||
| steps_extend(task.tasks) | ||
| continue |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
@kaiisfree please cross check this and other relevant suggestions
| """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. | ||
| """ |
…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>
|
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 Concern 2: Verified — the reverse walk applies args correctly. Concern 3:
Concern 4: Nested chain splicing with leading empty groups ✅ Valid concern, fixed! When 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). |
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>
Cross-check of Copilot's review concernsI've gone through each concern carefully against the actual code. Here's the analysis: Concern 1: Empty
|
| 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>
|
Round 3 cross-check — all 3 Copilot concerns addressed Concern 1: freeze() returning None — Added a docstring to Concern 2: Nested chain splicing with leading empty groups — Added 3 new regression tests covering post-splice empty group scenarios:
All pass. The existing code already handles these correctly: when 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). |
| 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()) |
There was a problem hiding this comment.
_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.
There was a problem hiding this comment.
@kai @kaiisfree can you please look into the remaining issues including this?
| 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() |
There was a problem hiding this comment.
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”)?
Addressing Copilot review concernsConcern 1: PR number references in test docstringsVerified — all test docstrings already reference issue #9772, not PR #10245. No changes needed. (You can confirm by searching the test file: Concern 2:
|
|
@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>
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, FIXEDThis one is valid. Here's the exact code path:
The same issue exists in Fix (commit 22cf5dd): Skip empty chains in both # In _prepared() and _freeze_unroll():
elif isinstance(task, _chain) and not task.tasks:
# Skip empty chains -- they are no-ops. (Issue #9772)
continueThis means when a chord header contains only empty chains, Concern 2 (CRITICAL — test_canvas.py:1335): Test doesn't verify chord completes — ADDRESSED BY CONCERN 1 FIXWith the Concern 1 fix, the empty chains are now stripped from the chord header during Concern 3 (MEDIUM — canvas.py:1239): Nested chain splice misses leading empty groups — NOT A REAL ISSUEThe 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
The existing tests Concern 4 (LOW — test_canvas.py:883): Docstrings reference PR instead of issue — FALSE ALARMAll 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 Concern 5 (from earlier round): freeze() return type — ALREADY ADDRESSED
Summary
All 211 canvas tests pass (1 xfail expected). |
| 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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
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>
There was a problem hiding this comment.
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
Summary
Fixes #9772
chain(group([task1, task2]), group(), group())crashes withAttributeError: 'NoneType' object has no attribute 'parent'because:group()is used as a chord body,chord.freeze()returnsNone(empty groups are falsy, so theif self.body:check inchord.freeze()skips body freezing)chain.prepare_steps(), the code then tries to traverseres.parenton theNoneresult at line 1277Changes
Three defensive fixes in
celery/canvas.py, all in the chain resolution path:prepare_steps: Empty groups are no-ops and should not participate in chain resolution or be upgraded to chordsresbeingNonebefore traversingnode.parentchain.freeze(): ReturnNoneinstead of raisingIndexErrorwhen all steps were skippedTest plan
test_chain_with_empty_group_does_not_crash— reproduces the exact crash fromchain(group([task1, task2]), group(), group())()throwsAttributeError: 'NoneType' object has no attribute 'parent'#9772test_chain_empty_group_between_tasks— validates empty groups between normal tasks🤖 Generated with Claude Code