Skip to content

Add Execution API endpoint for callback terminal state - #75253

Open
ferruzzi wants to merge 5 commits into
apache:mainfrom
aws-mwaa:callback-state-endpoint
Open

ferruzzi wants to merge 5 commits into
apache:mainfrom
aws-mwaa:callback-state-endpoint

Conversation

@ferruzzi

@ferruzzi ferruzzi commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Callback state was written only by the scheduler, reading each executor's in-process event buffer. Executors whose workers report back over HTTP never reach that channel, so their callbacks sat in QUEUED forever.

The executor-event path stays as a fallback, now narrowed to rows still in flight so a late event cannot overwrite an outcome the supervisor already reported, or the output captured with it.

Replaces #66141, whose /run half landed separately in #71192. Most of this is @wjddn279's code from that PR ported on top of the current main, co-author credit should carry over.


Was generative AI tooling used to co-author this PR?
  • [ x ] Yes (please specify the tool below)
    Inheriting the disclaimer from the parent PR: claude code (opus 4.7) to make test codes and ci green

  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

@fat-catTW fat-catTW left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the scheduler fallback could race with the new state endpoint here.

The scheduler reads the callback without a lock, checks that it is still QUEUED/RUNNING, and later writes the executor event's terminal state. In between those operations, the callback supervisor can successfully report SUCCESS (including its output) through PATCH /callbacks/{callback_id}/state.

For example, the callback subprocess can succeed and report SUCCESS, while the outer supervisor subsequently exits abnormally and the executor emits a late FAILED event. The scheduler could then overwrite the supervisor's already-recorded result, despite the intended first-writer-wins behaviour.

Maybe we could lock the callback row before rechecking its state, or use a conditional update that only succeeds while the persisted state is still QUEUED or RUNNING and add a regression test for this interleaving?

@fat-catTW fat-catTW left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lgtm
Thanks for the PR :D

@seanghaeli seanghaeli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also a conflict to deal with, but otherwise looks ready!

@ramitkataria ramitkataria left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The conditional UPDATE looks like a nice fix for the race. A few smaller things my agent found on top of the inline comments:

- +1 to the suggestion on `routes/callbacks.py` about returning 204 when the row is already in the requested state, so retries don't log a traceback.
- Which executors is this mainly for? Edge seems to already pass callback events back to the scheduler since #74784, so maybe worth naming the ones that don't in the description. For those, a failed `/run` or an OOM-killed supervisor would still leave the row stuck, so the comment at `callback_supervisor.py:469` might want a tweak, and maybe a follow-up issue for a timeout-based cleanup of stuck `QUEUED`/`RUNNING` callbacks?
- `jwt_token_authentication.rst` still only lists `/run` for callbacks, so maybe worth adding `/state` there too.
- After rebasing onto #75689, it might be worth double-checking the 404 case in `test_state_validates_token_scope_and_sub`, since it depends on the order the auth dependencies resolve in.

raise
finally:
try:
client.callbacks.finish(UUID(id), state=terminal_state, output=terminal_output)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The execution token from /run seems to only live for [execution_api] jwt_expiration_time (600s by default), and since the callback supervisor doesn't heartbeat, I don't think anything refreshes it while the callback runs. So a callback that runs longer than ~10 min without reading a Connection or Variable would get a 403 here, which isn't retried and gets swallowed by the except. On executors with no executor-event fallback, that would leave the row RUNNING. Maybe the supervisor could refresh the token periodically?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's also handle this in the same follow-up

update(Callback)
.where(
Callback.id == callback_uuid,
Callback.state.in_((CallbackState.QUEUED, CallbackState.RUNNING)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could a duplicate delivery (e.g. Celery acks_late redelivery) end up overriding a real success here? The second supervisor's /run would get a 409 and fail, this UPDATE would write FAILED since the row is RUNNING, and then the original supervisor's finish(SUCCESS) gets a 409 and its SUCCESS event is ignored. Before this change the later SUCCESS would have overwritten it. Not sure what the cleanest fix is, since this path still needs to fail a RUNNING row whose supervisor died, so mostly flagging it in case it's worth handling or noting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I see what you are saying. I think I can see a fix for it, but it would be a bigger change worth a followup. I'll think on it more, but the best answer I have right now would solve both of your points: We could maybe add a new column to the callback table for the heartbeat timestamp the same way TaskInstance has TaskInstance.last_heartbeat_at.

That would need a new column with a migration, a new PATCH endpoint to update it with a cadwyn migration, a new heartbeat loop in the supervisor to generate it, (three?) new config options to set the new heartbeat's timeout, a new reaper to clean up anything that goes stale... then once that's all in we can add the liveness check that makes sure that a FAILED event can only move a RUNNING row if the heartbeat is state. I think that would work but it feels heavy-handed... I'm sure there's a better way. Let me think on it some more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can't think of a better solution and that's a relatively big bite of work to close a race condition that already exists. Are you alright with throwing that in the backlog and getting this merged? I don't believe (I could be wrong) this is making the existing state worse than main or introducing a new bug, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, that makes sense - I haven't fully thought through solutions yet either

ferruzzi and others added 5 commits November 2, 2026 17:11
Callback state was written only by the scheduler, reading each executor's in-process event buffer. Executors whose workers report back over HTTP never reach that channel, so their callbacks sat in QUEUED forever.

The executor-event path stays as a fallback, now narrowed to rows still in flight so a late event cannot overwrite an outcome the supervisor already reported, or the output captured with it.

Replaces apache#66141, whose /run half landed separately in apache#71192.

Co-authored-by: wjddn279 <ehwjddn279@naver.com>
@ferruzzi
ferruzzi force-pushed the callback-state-endpoint branch from 8486c4e to 726dc55 Compare November 3, 2026 00:17

@o-nikolas o-nikolas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agreed about a separate PR for the callouts from @ramitkataria

terminal_output = f"Callback subprocess exited with code {exit_code}"
except Exception as e:
terminal_output = f"Callback supervisor error: {type(e).__name__}: {e}"
log.exception("Callback supervisor error", workload_id=id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: this log.exception plus raise gives two tracebacks for the same error, because the caller logs it too (LocalExecutor logs Workload execution failed. with the exception). Could you drop the log here and just record terminal_output?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, fair catch. I'll make this change and open the Issue for Ramit's follow-up on Monday.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants