Repository navigation
Airflow 3.2 Worker Failures on TaskInstance Finish (HTTP 409 invalid_state) #65708
Description
Under which category would you file this issue?
Task SDK
Apache Airflow version
3.2.0. & 3.2.1
What happened and how to reproduce it?
Issue Description
The observed behavior starts after upgrading to Airflow 3.2.0 (and persists in 3.2.1) and disappears when downgrading to 3.1.8.
The failure manifests on Celery workers during the post-execution "finish" step (Task SDK / execution API), where the worker attempts to PATCH the TaskInstance state and receives:
- HTTP 409
reason=invalid_statemessage=TI was not in the running state so it cannot be updatedprevious_state=success
This results in a worker-side exception (ServerResponseError) and causes the Celery workload to be treated as failed even when the underlying task logic has completed.
- Airflow version(s) affected: 3.2.0, 3.2.1
- Airflow version known-good: 3.1.8
- Executor: CeleryExecutor
- Deployment: Kubernetes
- Python: 3.12
- Database: Postgres
Typical worker log sequence:
- Worker starts and loads secrets backend
- Worker begins Task SDK API calls (often with retries)
- API returns 409 invalid_state with
previous_state=success - Supervisor raises
ServerResponseErrorand the Celery task fails
Example:
Secrets backends loaded for worker ...Starting call to 'airflow.sdk.api.client.Client.request', this is the 1st time calling it.Starting call to ... this is the 2nd time calling it.API server error ... status_code=409 ... previous_state='success'airflow.sdk.api.client.ServerResponseError: Server returned error
Steps to reproduce
The issue appears intermittently; higher concurrency tends to increase the frequency.
Prerequisites
- Airflow 3.2.0+
- CeleryExecutor
- Execution API enabled (Task SDK in use)
- Deploy Airflow 3.2.0 or 3.2.1.
- Trigger the DAG multiple times (or run multiple manual runs concurrently).
- Observe worker logs for
ServerResponseErrorwith HTTP 409invalid_stateandprevious_state=success.
What you think should happen instead?
Airflow workers should be able to finalise a task idempotently; if the API server has already moved the TaskInstance to a terminal state (e.g. success), the worker’s PATCH /execution/task-instances//state should not return a fatal 409 or fail the task.
Operating System
No response
Deployment
Official Apache Airflow Helm Chart
Apache Airflow Provider(s)
No response
Versions of Apache Airflow Providers
No response
Official Helm Chart version
Not Applicable
Kubernetes Version
No response
Helm Chart configuration
No response
Docker Image customizations
No response
Anything else?
No response
Are you willing to submit PR?
- Yes I am willing to submit a PR!
Code of Conduct
- I agree to follow this project's Code of Conduct
Activity
serhii-karelov commentedon Apr 23, 2026
We noticed the problem where heartbeat DB queries queue up. This makes workers timeout on the heartbeat request and retry. I can imagine that the failed heartbeat can put task instance into a terminal state (but not sure though).
DominikBalga commentedon Apr 23, 2026
after further investigation it seems that the issue we are experiencing is the combination of the idle transactions (i do see idle transactions up to 50 secs durararion as well) and issue described here #63355
KeeganCarter11 commentedon May 8, 2026
I patched this issue my MWAA Local runner in a kinda ugly way - I'm sure that someone with more experience can take this further.
inside
airflow/sdk/api/client.pyI replaced the floowingwith
Shimotsuk1-Rei commentedon May 15, 2026
well I think things are the same under v3.1.8 with python3.10, worker logs as follow:
worker log
we can see the TI UUID is: 019e266e-b5e5-7e40-b3ec-a6eba4da280e
worker traceback log
check apiserver ingress log, status 409 found:
but here is something interesting, from ingress log, state update request was only sent once, however, we found update logs in 2 different apiserver pod:
seanmuth commentedon Sep 9, 2026
Adding a data point from an Astro Hosted deployment (Astronomer's managed Airflow), Runtime 3.1-15 (Airflow 3.1.8) — confirms this is not limited to 3.2.x, and confirms the concurrency-correlation observation with actual numbers.
Same exact signature, both variants seen in this thread:
Starting call to 'airflow.sdk.api.client.Client.request'...→API server error, status_code=409, previous_state=success(supervisor.py:617, uncaught) → task exits withexit_code=1,final_state=up_for_retry, despite the task's own work having already completed successfully.previous_state=successconflict, caught by the existingServerResponseErrorhandler (supervisor.py:1115,"Server indicated the task shouldn't be running anymore") and just SIGTERMs the process — no crash, no misleadingup_for_retry.Confirms PR #63355 is the right shape of fix ("if API says TI is already in requested state... task runner will not fail the process") — verified it's not present in 3.1.8/3.2.x (
git compareagainst the merge commit: diverged from 3.1.8, 3.2.0, 3.2.1, 3.2.2; first contained in 3.3.0).New evidence on the concurrency correlation: cross-environment comparison over the same 24h window, filtering on this exact error signature — one high-task-density environment showed ~487 occurrences across its cluster, while a much lower-density environment (older Runtime, same platform, no shared code) showed only 5. On the DB side: RDS Performance Insights (Database Load, Top SQL) looked unremarkable around one occurrence, but that view is built from active/CPU-consuming sessions and wouldn't surface an idle-in-transaction pileup even if present. Broader CloudWatch RDS metrics for that same window show a real, if modest, correlation: write IOPS/throughput/transaction-log generation all spike right at the time of the failure, with DB load, connection count, and CPU climbing over the following several minutes (DB load ~14→20, connections ~252→316, CPU ~46%→53%). Not dramatic, but a genuine load increase lands right on the incident — consistent with the heartbeat-queuing theory raised earlier in this thread, though we can't confirm the specific mechanism (e.g., can't rule in/out idle-in-transaction sessions specifically). We're now watching
pg_stat_activitylive (state != 'idle', runtime > 5s) to try to catch it during a genuine load spike.One detail from an earlier comment worth a second look: the report showing ingress logs with only one state-update request sent, yet two different apiserver pods logging update activity for the same TI. We saw something adjacent to this — two separate
"...this is the 1st time calling it"tenacity-beforelog lines within ~300ms of each other in the same worker pod's log, for what should be non-competing calls. Neither of us has a full explanation for that piece yet; flagging in case it points at something below the request-retry layer (e.g. a stale/pooled connection landing on a different backend pod) rather than a pure application-level duplicate call.Filed by Claude (Sonnet 5) on behalf of Sean Muth, from a support investigation on an Astro Hosted deployment.
seanmuth commentedon Sep 14, 2026
Root cause of the intermittent
409 invalid_stateon TI completionFollow-up to the earlier comment with cross-environment concurrency correlation. We built a live instrumented repro (fanout burst of task completions under
AstroExecutor, wrappingClient.request()andTaskInstanceOperations.succeed/finish/heartbeatto log full call-site stacks + monotonic sequence numbers) and reproduced the exact signature under load. Full mechanism, confirmed via direct stack-trace evidence on both sides of the call:Sequence:
SucceedTask→Supervisor._handle_request()setsself._terminal_state = msg.state("success") and callsclient.task_instances.succeed()synchronously →PATCH .../state→ apiserver returns204, row updated, both sides confirm the round-trip.update_task_state_if_needed()fromwait(), which calls.finish()again → secondPATCH .../state→ apiserver correctly rejects it (previous_state=success) →409.ServerResponseErrorhandling insidehandle_requests()), so the409propagates uncaught → "Task supervision failed" → task ends upexit_code=1, and gets markedup_for_retryeven though the DB already correctly hassuccess.Why the guard that should prevent this (
STATES_SENT_DIRECTLY, which includesSUCCESS) doesn't fire:update_task_state_if_needed()checksself.final_state, notself._terminal_statedirectly.final_state's property body gates onself._exit_codefirst:_monitor_subprocess()'s loop (while self._exit_code is None or self._open_sockets:) only exits once the OS exit code is observed and every child socket (stdout/stderr/logs/requests) has reported EOF. Under scheduling delay, the non-blocking exit-code poll (_check_subprocess_exit, viapsutil) can lose the race against socket closure: all four sockets can legitimately drain and close — including the requests socket having already delivered and processedSucceedTask— in a cycle where the exit-code poll simply hasn't yet observed the process's realexit_code=0.wait()then does:silently defaulting a never-actually-observed exit code to
1. Becausefinal_statechecksself._exit_code == 0before ever consultingself._terminal_state, this default discards the already-known-good, message-reported_terminal_state = "success"and recomputes a fallback state from the wrong exit code — landing outsideSTATES_SENT_DIRECTLY— which is what makesupdate_task_state_if_needed()decide to call.finish()a second time.This is a precedence bug, not a missing guard: the supervisor already has authoritative information (the child told it directly, and the network round-trip for
.succeed()confirmed it), but that information gets thrown away in favor of a defaulted, never-confirmed exit code.Proposed fix — local ordering fix, no additional network/DB round-trip: trust
self._terminal_statewhenever a terminal message was actually received, independent of whether the exit code was genuinely observed or defaulted; only fall back to exit-code-derived state when no terminal message arrived at all:This differs from #63355 (merged, released 3.3.0), which swallows the resulting 409 at the API layer via an idempotency check — a correct and worthwhile hardening on its own, but a band-aid for the symptom. This fix addresses why the redundant call happens at all: it's a single-line reordering, purely local to the supervisor, costs nothing extra over the wire or in the DB.
Repro details available on request (anonymized instrumentation + burst-fanout DAG); happy to share as a reference if useful.
We'll open a PR with this fix.
Drafted-by: Claude Sonnet 5 (reviewed by @seanmuth)
Fix task incorrectly retried after a terminal state was already reported
seanmuth commentedon Sep 15, 2026
Correction and new evidence: the mechanism I described earlier was wrong
My comment above (and the framing in PR #73142's current docstring) described this as "an unobserved/defaulted exit code overriding an already-confirmed terminal state." That explanation was inference from source reading, not something I'd actually observed, and it doesn't hold up. I built live instrumentation (wrapping the relevant
Client/ActivitySubprocesscall sites in a plugin, no application behavior changed) and ran it under a deliberately CPU-saturated burst workload (10k mapped tasks, worker concurrency pushed past comfortable capacity) to catch a real occurrence. Retracting the earlier explanation and posting what actually happens, with full tracebacks.What's actually happening
Two independent, separately-caused duplicate writes hit the same task instance, 20 seconds apart. Full apiserver-side timeline for one occurrence (times UTC, anonymized deployment):
First duplicate (the 204→409 pair, 2.7s apart): this is client-side tenacity retrying
.succeed()'s PATCH after a timeout/connection hiccup on the client side, even though the server had already fully processed and committed the first physical request.Client.request's retry predicate only retries onhttpx.RequestErroror a5xx— a409is neither, so once the retry lands on an already-successrow, tenacity gives up immediately (reraise=True) and raises. This happens entirely insideClient.request(), upstream of any application-level state tracking.Update: this is already fixed. PR #63355 added an idempotency short-circuit to this exact endpoint (
airflow-core/.../execution_api/routes/task_instances.py): if the requested state already matchesprevious_state, it returns a plain200instead of raising the409. That covers this first duplicate exactly (retried.succeed()requestingstate=successagainst an already-successrow). #63355 shipped in 3.3.0 alongside #66574, and — like #66574 and #67115 — is not present on the 3.1.x/3.2.x branch this was reproduced against.Second duplicate (the later 409, and the one that actually crashes the worker): full traceback, identical across all 7 live occurrences I captured in one run (only
ti_idand timestamp differ):update_task_state_if_needed()computesfinal_statefromexit_codein a way that doesn't correctly defer to the already-known_terminal_state='success'here, decides it needs to call.finish(), and that call hits the exact same "already success" guard — except this exception is genuinely uncaught inwait()/supervise(), and it's this one that kills task supervision. This is exactly the scenario PR #73142 targets. I traced it end to end against #73142's fix: with correct precedence,final_stateresolves toSUCCESS, lands inSTATES_SENT_DIRECTLY, and.finish()is never called — zero calls, not a caught failure.I also want to flag PR #66574 ("Recover stuck TIs when direct terminal-state API call fails," in 3.3.0+) as related but distinct: it defers when
_terminal_stategets set and givesupdate_task_state_if_needed()a safe replay path instead of the buggy fallback. In my traced scenario it would prevent the crash (the replay's failure is caught and logged rather than propagated) but it does not prevent the duplicate send — the replay itself re-issues.succeed()again, hitting the same 409 a third time. #73142 is the one that actually eliminates that second call outright.Net: two independent bugs, three total duplicate/redundant
PATCH .../statecalls for one task completion, on one confirmed live occurrence. #73142 fixes the second (by not calling.finish()at all). #63355 already fixes the first (by making the endpoint a no-op when the retried request matches the already-persisted state). Both, along with #66574, ship together in 3.3.0; none are backported to the 3.1.x/3.2.x line this was reproduced against.Drafted-by: Claude Code (Sonnet 5); reviewed by @seanmuth before posting
Task SDK: don't let exit code override a confirmed terminal state
Task SDK: don't let exit code override a confirmed terminal state