tasks: abort the collection chain when a play fails - #2630
Merged
Conversation
ideaship
force-pushed
the
fix/abort-collection-chain-on-ansible-failure
branch
from
August 27, 2026 13:07
b4a3185 to
86e7f29
Compare
ideaship
force-pushed
the
fix/abort-collection-chain-on-ansible-failure
branch
from
August 27, 2026 13:32
86e7f29 to
798d3f7
Compare
berendt
force-pushed
the
fix/abort-collection-chain-on-ansible-failure
branch
from
August 27, 2026 13:53
798d3f7 to
4ffe585
Compare
ideaship
marked this pull request as ready for review
August 27, 2026 13:57
There was a problem hiding this comment.
Hey - I've reviewed your changes and they look great!
Sourcery assessment
Needs a human reviewer. If the new failure propagation is wrong, a non-zero play result can abort a collection after earlier tasks have already changed production, leaving a partially applied deployment that reverting this code will not undo. The resulting infrastructure state is bounded and can be repaired or rerun, but it may already cause an outage or require manual remediation.
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
ideaship
force-pushed
the
fix/abort-collection-chain-on-ansible-failure
branch
2 times, most recently
from
August 27, 2026 14:06
4564d39 to
b0ef3cc
Compare
berendt
self-requested a review
August 27, 2026 16:03
run_ansible_in_environment captured the Ansible exit code, wrote it to the Redis output stream and to the execution-history JSON, and then returned the accumulated output string. Nothing raised, so Celery marked the task SUCCESS regardless of the rc and fired the next link in the chain. The non-collection CLI path never noticed, because it reads the rc back out of the Redis stream (handle_task -> fetch_task_output) rather than out of the Celery result. The collection path has only Celery's view of the task, and Celery's view was always SUCCESS. So osism apply <collection> kept dispatching every dependent role after an early play had already failed permanently. In builds 7e89e00b and 3b6c5232 (2026-08-27, periodic-midnight) keystone bootstrap failed permanently at 01:02:41 and five service-ks-register plays then failed after five retries each with HTTP 503. Those failures were explicit and terminal, and the chain carried on regardless: from 01:04:07 to 04:30:26 the console log is nothing but two celery tasks in STARTED and a poll loop, job-output.json records 0 failed tasks, and Zuul killed both jobs at 4h31m. Raise AnsibleFailure when the rc is non-zero. The raise is placed after log_play_execution() and finish_task_output(), so the history record and the streamed rc are unchanged and every user-visible exit code on the non-collection path stays exactly as before; the only difference there is that the task now ends in state FAILURE rather than SUCCESS. Because osism/tasks/__init__.py is shared, this covers ansible.run, ceph.run, kolla.run and kubernetes.run alike. The lock release and the per-task SSH ControlPath cleanup already live in finally blocks, so the raise passes through them. The exception message has to carry its own context. With the JSON result serializer Celery stores only the exception type and its string form, so the message names the worker, environment, role and rc; the play output reaches the operator over the Redis stream, not through the result backend. That alone is not enough to make a deploy fail. osism wait branched on PENDING, SUCCESS and STARTED only, so a FAILURE task fell through every branch, was dropped from the poll queue without being re-queued, and rc stayed 0. testbed's deploy-in-a-nutshell.sh runs "osism wait --output --refresh 20" under set -e, so an aborted chain would still have been reported as a successful deploy. Add a FAILURE/REVOKED branch that sets rc = 1. It deliberately does not call result.get() even under --output: Celery re-raises the task's exception from there, which would replace the exit code with a traceback. handle_loadbalancer_task is the only place that calls .get() on one of these tasks, so it is the only place the new exception can surface in the CLI. Absorb AnsibleFailure there - the rc has already been read from the output stream by handle_task, so the exception carries nothing new - and absorb only that type. Using get(propagate=False) instead would have swallowed every exception and erased the existing, deliberate behaviour that an unexpected group failure propagates. One intended side effect: the periodic gather_facts task also runs through this function, so a failed facts run now reports FAILURE instead of SUCCESS. That is the correct state for it and nothing chains off it. This does not bound how long a play waits for something that never arrives. The kolla-wait-for-nova and kolla-wait-for-keystone plays have a 5h09m worst case of their own, which is a separate defect in container-image-kolla-ansible. Assisted-by: Claude:claude-opus-5 Signed-off-by: Roger Luethi <luethi@osism.tech>
handle_collection fell off the end of the function and so returned None.
take_action assigns that to rc and then tests it with "if rc != 0", and
None != 0 is True, so a successfully scheduled collection was treated as
a failure: outer_break was set and the remaining "//" segments were
silently skipped.
osism apply nutshell//myrole therefore scheduled the collection and then
dropped myrole without a word. The process still exited 0, because cliff
turns take_action's None into 0 ("return_code = self.take_action(...) or
0"), so nothing surfaced the skip.
Return 0 explicitly. This says only that the collection was scheduled,
which is all the fire-and-forget collection path can know - the roles
run in the background and are waited for separately with osism wait.
The existing test for this was pinned as a strict xfail; it now passes
and the marker is removed. A direct test of the return value is added
alongside it, since the xfail'd test reaches it only indirectly through
the "//" loop.
Assisted-by: Claude:claude-opus-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
berendt
force-pushed
the
fix/abort-collection-chain-on-ansible-failure
branch
from
August 27, 2026 16:04
b0ef3cc to
29cbc45
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
osism apply <collection>keeps dispatching dependent roles after an early play has already failed permanently. A nutshell deploy whose keystone bootstrap dies in the first two minutes runs to the 4h30m Zuul job timeout and reports zero failed tasks.The bug
run_ansible_in_environmentcaptures the Ansible exit code, writes it to the Redis output stream and to the execution-history JSON — and then returns the accumulated output string. Nothing raises, so Celery marks the taskSUCCESSregardless of the rc and fires the next link in the chain.The single-role path never noticed, because it reads the rc back out of the Redis stream (
handle_task→fetch_task_output) rather than out of the Celery result. The collection path has only Celery's view of the task, and Celery's view was alwaysSUCCESS.Last seen in the 2026-08-27 periodic-midnight run, in both the
currentandnextlanes — and the cleanest instance on record, because no failure detection subtlety is involved:service-ks-registerplays (designate, barbican, neutron, placement, magnum) then each failed after 5 retries withDiscoveryFailure … Service Unavailable (HTTP 503)STARTEDand a poll loopjob-output.jsonrecords 0 failed tasks; both jobs TIMED_OUT at 4h31mBuilds:
7e89e00b(current),3b6c5232(next). Ansible reported terminal failures 3h26m before the kill.The fix
Raise
AnsibleFailureon a non-zero rc, placed afterlog_play_execution()andfinish_task_output(). The history record and the streamed rc are unchanged, so every user-visible exit code on the single-role path stays exactly as before; the only difference there is that the task now ends inFAILURErather thanSUCCESS. Becauseosism/tasks/__init__.pyis shared, this coversansible.run,ceph.run,kolla.runandkubernetes.runalike. The lock release and the per-task SSH ControlPath cleanup already live infinallyblocks, so the raise passes through them.The exception message carries its own context (worker, environment, role, rc): with the JSON result serializer Celery stores only the exception type and its string form, and the play output reaches the operator over the Redis stream rather than the result backend.
That alone does not make a deploy fail.
osism waitbranched onPENDING,SUCCESSandSTARTEDonly, so aFAILUREtask fell through every branch, was dropped from the poll queue without being re-queued, andrcstayed 0.testbed/scripts/deploy-in-a-nutshell.shrunsosism wait --output --refresh 20underset -e, so an aborted chain would still have been reported as a successful deploy. AFAILURE/REVOKEDbranch now setsrc = 1. It deliberately does not callresult.get()even under--output: Celery re-raises the task's exception from there, which would replace the exit code with a traceback.handle_loadbalancer_taskis the only place that calls.get()on one of these tasks, so it is the only place the new exception can surface in the CLI. It absorbsAnsibleFailure— the rc has already been read from the output stream byhandle_task, so the exception carries nothing new — and absorbs only that type.get(propagate=False)would have swallowed every exception and erased the existing, deliberate behaviour that an unexpected group failure propagates (pinned bytest_handle_loadbalancer_task_group_failure_propagates).Second commit, an independent latent bug found in the same blast radius:
handle_collectionfell off the end of the function and returnedNone.take_actiontests that withif rc != 0, andNone != 0is True, so a successfully scheduled collection was treated as a failure and the remaining//segments were silently skipped —osism apply nutshell//myroledroppedmyrolewithout a word, still exiting 0. The repo already pinned this as a strict xfail; it now passes and the marker is removed.Verification
Chain abortion, against a real broker. The load-bearing assumption is that raising actually stops the nested
chain(task, group(...))tree that_handle_collectionbuilds. Verified with a real Celery 5.6.3 worker against a throwaway Redis, on a tree mirroring that structure:common,keystone,mariadb,memcached,openvswitch,ovncommon,openvswitch,ovnThe dependent subtree is skipped and the independent sibling branch still completes — which is the intended behaviour, not just a stopped chain. Also confirmed directly that the resulting state string is literally
FAILUREand thatresult.get()re-raises, which is what the two points above depend on.Tests. 2905 passed, 2 xfailed (both pre-existing and unrelated). New coverage: the raise happens after the rc is logged and published; the message carries its context; the redlock and SSH ControlPath dir are still released on the failure path;
FAILURE/REVOKEDset a non-zero exit; a failure is not reset by a later successful task in the queue;--outputdoes not call.get()on a failed task; script format prints the failure state;handle_loadbalancer_taskabsorbsAnsibleFailurebut still propagates others.flake8, black and mypy pass at the versions pinned in
.zuul.yaml.Not in scope
This does not bound how long a play waits for something that never arrives. The
kolla-wait-for-nova/kolla-wait-for-keystoneplays have a 5h09m worst case of their own — an independent defect that co-fires in exactly these builds, and that would not fix this one (with the wait bounded, the chain still carries on, every task still reportsSUCCESS, and the deploy still reports success):One intended side effect: the periodic
gather_factstask also runs through this function, so a failed facts run now reportsFAILUREinstead ofSUCCESS. That is the correct state for it and nothing chains off it.🤖 Generated with Claude Code