Skip to content

Apply the configured XCom backend to language SDK task XComs - #75184

Open
jason810496 wants to merge 13 commits into
apache:mainfrom
jason810496:feature/lang-sdk/xcom-backend-in-coordinator
Open

jason810496 wants to merge 13 commits into
apache:mainfrom
jason810496:feature/lang-sdk/xcom-backend-in-coordinator

Conversation

@jason810496

@jason810496 jason810496 commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Changes since the third review, which leaves out the rebase so only the review response shows. 3ec4021 is the reviewed tree replayed onto the current base.

Why

Run the configured [core] xcom_backend at the supervisor, so TypeScript, Go and Java tasks store, read and purge XComs through it like Python tasks.

The backend runs in a helper process rather than in the supervisor itself:

  • The supervisor runs in the long-lived worker process, which also forks Python task processes. Backend state left there, such as the fsspec filesystem that XComObjectStorageBackend caches, breaks Python tasks forked from it later (This class is not fork-safe).
  • A backend call blocks on I/O. With the call in another process, the supervisor keeps heartbeating and can stop it on execution_timeout or when the server stops the task.

The helper starts on the first call the configured backend handles, so tasks on the default BaseXCom never start it.

How

  • The supervisor of a language SDK task runs the backend on the runtime's XCom requests (SetXCom, GetXCom, GetXComSequenceItem, GetXComSequenceSlice, DeleteXCom) and on the XComs it clears when a try starts. With the default BaseXCom, requests go to the API server unchanged.
  • The backend runs in a helper process, started per task the same way a Python task process is started (bare fork, or a new interpreter on macOS or with [core] execute_tasks_new_python_interpreter). So the long-lived worker process keeps none of the backend's state, such as a cached S3FileSystem that a Python task forked from it later could not use.
  • Runtimes get what the default backend would give them: the value is decoded with serde before store_value, and a loaded value is encoded with serde again. skipmixin_key bypasses the backend for every task, since the scheduler reads it from the XCom row as it is. Operator extra-link keys (_link_*) bypass it for language SDK tasks only, so Python tasks keep the behaviour they have today.
  • A backend call that fails fails the task. XCom that cannot be read or purged fails the task that is what a Python task does.
  • The backend looks up Connections and Variables through the supervisor's API client.
  • The supervisor services that process from the same request loop as the task, so a backend call blocks neither the loop nor its heartbeating, and a task whose backend hangs still stops at its execution_timeout.
  • A backend call that fails — the backend could not run, or refused, the XCom — reaches the runtime as its own XCOM_BACKEND_ERROR rather than API_SERVER_ERROR, which would point a user at the API server instead of at their own backend.
  • A new lang_sdk_xcom_object_storage e2e mode runs a Dag definition in each of TypeScript, Go and Java with XComObjectStorageBackend on LocalStack.

The backend needs no SDK change, since all three runtimes only talk to the supervisor. With this config, TypeScript, Go and Java tasks read and write offloaded XComs like Python tasks:

[core]
xcom_backend = airflow.providers.common.io.xcom.backend.XComObjectStorageBackend

Lifecycle

sequenceDiagram
    participant Runtime as Runtime (TS, Go, Java)
    participant Supervisor
    participant Backend as XCom backend process
    participant API as API server
    participant Storage as Object storage

    Note over Supervisor,Backend: The XCom backend process starts on the first call the backend handles,<br/>by fork, or by exec on macOS or with execute_tasks_new_python_interpreter
    Supervisor->>API: get and delete the XComs of the earlier try
    Supervisor->>Backend: purge(row)
    Backend->>Storage: delete object
    Runtime->>Supervisor: SetXCom(key, value)
    Supervisor->>Backend: store(coordinate, key, value)
    opt a Connection the backend's own secrets backends do not have
        Backend->>Supervisor: GetConnection
        Supervisor-->>Backend: Connection, read from the API server
    end
    Backend->>Storage: write value
    Backend-->>Supervisor: s3://... reference
    Supervisor->>API: set the XCom row to the reference
    Runtime->>Supervisor: GetXCom(task_id, key)
    Supervisor->>API: get the XCom row
    Supervisor->>Backend: load(row)
    Backend->>Storage: read value
    Backend-->>Supervisor: value
    Supervisor-->>Runtime: value
    Note over Supervisor,Backend: The supervisor answers the runtime once the call returns, and goes on heartbeating<br/>and enforcing execution_timeout while it runs
    Runtime->>Supervisor: exits
    Supervisor->>Backend: exit
Loading

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Code (Opus 5.5) following the guidelines

@jason810496
jason810496 force-pushed the feature/lang-sdk/xcom-backend-in-coordinator branch from 5fa6540 to e77b09a Compare October 20, 2026 06:20
@jason810496 jason810496 added this to the Airflow 3.4.0 milestone Oct 20, 2026
@jason810496 jason810496 self-assigned this Oct 20, 2026
@jason810496
jason810496 force-pushed the feature/lang-sdk/xcom-backend-in-coordinator branch 3 times, most recently from 462f4c9 to 848611a Compare October 20, 2026 11:12
@jason810496
jason810496 marked this pull request as ready for review October 20, 2026 11:12
@ashb

ashb commented Oct 20, 2026

Copy link
Copy Markdown
Member

Having a whole separate process to handle xcom uplodads feels overkill. Why can't we do it from the coordinator process directly? The "why" in the PR desc doesn't address this

@jason810496

Copy link
Copy Markdown
Member Author

Having a whole separate process to handle xcom uplodads feels overkill. Why can't we do it from the coordinator process directly? The "why" in the PR desc doesn't address this

Good point, I've added this to the Why in the description:

  1. Fork safety. The supervisor runs inside the long-lived worker process (for example the Celery worker child), which also forks the Python task processes. A backend run there leaves its state in that process: XComObjectStorageBackend caches its fsspec filesystem (_STORE_CACHE in airflow.sdk.io.store), and a Python task forked from that process later fails with RuntimeError: This class is not fork-safe.
  2. The supervisor stays in control. A backend call blocks on I/O. In the supervisor, a hung upload would block the loop that heartbeats and enforces execution_timeout, with no way to interrupt it. In a separate process, the supervisor keeps heartbeating during the call, and kills the helper on execution_timeout, when the server stops the task, or after too many failed heartbeats.

The helper is also lazy and per task: it starts on the first call the configured backend handles and serves all of that task's XCom calls, so tasks on the default BaseXCom never start it.

It is also not meant for XCom only. Any worker-side backend that runs in a Python task's process and keeps in-memory state should run in this helper for the same isolation, so the [workers] state_store_backend and the worker's secrets backends are next.

@Andrushika

Andrushika commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

My 2 cents... I like the shape of keeping the backend out of the supervisor. But making this new XCom process a mini event loop (heartbeat, its own execution_timeout) feels kinda heavy... Is it possible to make the supervisor just hand the call to the helper and go back to its normal loop?

Then the only thing the XCom process does is run the backend call itself, and we can still monitor the XCom helper process from the same selector we use for the task runner socket. The supervisor can also get the result by listening on the XCom helper's socket, and reply to the task runner once it's done.

Maybe there's a reason this doesn't work -- curious what you ran into.

@jason810496
jason810496 marked this pull request as draft October 22, 2026 01:53
@jason810496
jason810496 force-pushed the feature/lang-sdk/xcom-backend-in-coordinator branch from 848611a to a0675cc Compare October 22, 2026 04:34
@jason810496
jason810496 marked this pull request as draft November 2, 2026 07:40
@jason810496
jason810496 force-pushed the feature/lang-sdk/xcom-backend-in-coordinator branch 2 times, most recently from 9dd3243 to 54db61b Compare November 2, 2026 09:33
@jason810496
jason810496 marked this pull request as ready for review November 2, 2026 09:52

@jason810496 jason810496 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for the review, I just updated this PR and #75942 which this PR depends on.

Comment thread java-sdk/sdk/build.gradle.kts Resolved
Comment thread go-sdk/capabilities.yaml Resolved
Comment thread task-sdk/src/airflow/sdk/exceptions.py Resolved
@jason810496
jason810496 requested a review from uranusjr November 3, 2026 02:03
@jason810496
jason810496 force-pushed the feature/lang-sdk/xcom-backend-in-coordinator branch 2 times, most recently from b0bc7b1 to c1c646f Compare November 3, 2026 02:21
---
sdk: java

min_airflow_version: "3.4.0"

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.

This needs changing?

@jason810496 jason810496 Nov 4, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'd like to it as-is. Explained in the comment below.

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.

On 3.4 this isn't only about the XCom backend. The 3.4 bundle has no 2027-02-28, so resolve_version rejects it and every Java task fails, not just XCom backend calls. That's why main moved Go and TS to 3.5.0 with the same pin in #75387.

The Java pin can stay at 2026-10-30, since nothing here needs XCOM_BACKEND_ERROR on the Java side and #75942 decouples the pin from the vendored schema. If you'd rather bump the pin, min_airflow_version must be 3.5.0.

Comment thread java-sdk/README.md Resolved
@jason810496
jason810496 force-pushed the feature/lang-sdk/xcom-backend-in-coordinator branch from 2ffad21 to 57b2c39 Compare November 5, 2026 11:47
@jason810496
jason810496 requested a review from uranusjr November 5, 2026 11:51
The vendored copy was refreshed by downloading the version named in
gradle.properties. The remote only publishes released versions, so an
in-progress one cannot be fetched, and a schema change that leaves
api_version alone moved nothing, so the copy went stale unnoticed.

It is now copied from the schema the Python Task SDK owns and compared by
content, the way sync-go-sdk-schemas already does for the Go SDK, so any
change to the source moves it in the same commit. The download stays as the
fallback for a build with no monorepo around it, which is what the source
distribution needs.

The refreshed copy is the content it never received while it was refreshed
by version; api_version is unchanged.
The pin is the supervisor schema version the Java SDK speaks; the vendored
schema.json is a copy of whatever the bundle carries. Requiring the two to be
equal made the pin follow main every time a supervisor version was cut.

The prek hook now copies the source by content and only checks that the pin is
a version the bundle defines, and the Gradle task copies the in-repo source
whenever it exists instead of falling back to the download on a version
mismatch, so the build and the hook agree.
TypeScript, Go and Java tasks sent their XComs to the API server without the
configured XCom backend. They read the stored reference of a value that a Python
task wrote, such as an S3 path, instead of the value, their own values stayed in
the metadata database, and clearing a try left stored objects behind.

The backend runs in a helper process started like a Python task process, so the
long-lived worker process keeps none of its state, such as a cached filesystem
that a task process forked from it later cannot use.
No e2e test checked that TypeScript, Go and Java tasks store and read their
XComs through the configured XCom backend. The new lang_sdk_xcom_object_storage
mode runs one Dag definition in each language SDK with XComObjectStorageBackend
on LocalStack, and checks the stored objects and the values the tasks read back.
Changes to the XCom backend sources also run the language SDK e2e jobs.
A runtime sends its XCom requests without waiting for the previous answer, so a
backend call can start while another runs. The supervisor kept one pending call,
so a second request dropped the first one's answer and the runtime waited for a
response that never came.

The supervisor now keeps the pending calls in a queue and answers them in the
order the backend process runs them, which is the order they were sent.
The XCom backend process now owns the callback of each call it is sent, so the
supervisor keeps no queue of its own and nothing has to stay in sync. A process
that has exited, or that does not answer a call the supervisor waits for, is
stopped and replaced rather than reused.

A value the backend cannot be given, and a row it cannot load, now fail the
request. Keeping them in the XCom row instead put the payload in the database,
which is what the backend is configured to avoid.

Every backend but the default one runs in the helper process, rather than the
helper deciding from the hooks a backend happens to override.

Operator extra links keep bypassing the backend for a language SDK task, where
the key is all the supervisor has to go on, but no longer for a Python task,
which already writes them past the backend.
A failure of the configured XCom backend reached the runtime as
API_SERVER_ERROR, which points a user at the API server rather than at their
own backend. It now has its own XCOM_BACKEND_ERROR, and the loop guard answers
with it instead of raising.

A runtime pinned to a supervisor schema version that has no type for it gets
GENERIC_ERROR, through the version change this adds.
2026-10-30 shipped with 3.4.0rc1, so it no longer takes changes: per
contributing-docs/19_execution_api_versioning.rst a migration may only be
added to an unreleased version. The execution API already cut 2027-02-28 for
the same reason; the supervisor bundle had not followed.

AddXComBackendErrorType moves to the new version, so a runtime pinned to
2026-10-30 keeps seeing the generic error type it knows. api_version follows
the bundle's newest version, so the Go, TypeScript and Java copies and the
version each SDK declares move with it.
XCom.delete guards neither the read nor the purge, and the task runner
clears the XComs of an earlier try inside the block that fails the task, so
for a Python task any of the three failing fails the task. The supervisor
caught all three and only warned, so the same backend failure stopped a
Python task and let a language SDK task run on.

All three now propagate: the per-try clear fails the try, and a DeleteXCom
request is answered with XCOM_BACKEND_ERROR, which fails the task from the
runtime. _call_xcom_backend loses its on_error hook, which nothing else used.
- Reap a helper process that is known to be dead without servicing the
  selector it shares with the task, which would otherwise dispatch the
  task's own requests re-entrantly and start a second helper.
- Fail the calls a stopped process never answered, and the call whose
  send raises, instead of leaving the runtime waiting for an answer.
- Give the lang_sdk_xcom_object_storage e2e mode its own file group and
  run-lang-sdk-xcom-e2e-tests output, so a change to the XCom backend no
  longer runs the Java, Go and TypeScript SDK e2e jobs as well.
- Declare a custom-xcom-backend capability in the three SDK matrices,
  supported since Airflow 3.5.0.
- Drop comments that restate the code or belong in the PR description.
At task end a call can still be running in the helper. Its answer handed
the helper the next queued call, so a store could write an object for a
task that was already over, and no exit was sent, so stopping waited out
the full timeout. Answering a runtime that had already exited raised
OSError out of the supervisor's monitor loop, which skipped reporting the
task state and uploading its logs.
Upstream added HITLDetailResponseResult to the task-sdk supervisor schema,
so the Java SDK copy vendored from it drifted after the rebase.
@jason810496
jason810496 force-pushed the feature/lang-sdk/xcom-backend-in-coordinator branch from 57b2c39 to e8db061 Compare November 6, 2026 01:36
Several comments went on to explain the consequences of the code they
annotate, which the review asked to drop.
@jason810496
jason810496 requested a review from uranusjr November 6, 2026 06:02

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handle custom xcom backends with go sdk (edge mode)

4 participants