Skip to content

Add WebSocketSensor and WebSocketTrigger to the standard provider - #72133

Open
ColtenOuO wants to merge 6 commits into
apache:mainfrom
ColtenOuO:websocket-trigger-standard-provider
Open

Add WebSocketSensor and WebSocketTrigger to the standard provider#72133
ColtenOuO wants to merge 6 commits into
apache:mainfrom
ColtenOuO:websocket-trigger-standard-provider

Conversation

@ColtenOuO

@ColtenOuO ColtenOuO commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Deferrable operators that hand a long-lived request off to a remote server currently have no built-in way to resume once that server replies over a WebSocket connection. This closes #21139, which asked for exactly this: a trigger that opens a WebSocket connection, hands off the wait to the triggerer, and fires once the remote server replies.

Why we need this

Without this, the only options today are a plain HTTP-based deferrable sensor/trigger that polls, or a non-deferrable operator that blocks synchronously, and both have real costs for a long-running, unknown-duration remote job. Polling means repeated API calls against the remote server for the entire duration of the job — on a poke_interval of a few seconds that adds up fast over a job that can run for hours, and it puts avoidable load on a server that has no way to tell Airflow "I'm still working" other than answering yet another request. Polling also caps how quickly Airflow finds out the job is done: the result only surfaces on the next poll, so the effective latency is bounded by poke_interval, not by when the server actually finished. A synchronous, non-deferrable wait avoids the polling cost but instead ties up a full worker slot for the whole duration, which does not scale when many such jobs run concurrently. A WebSocket trigger avoids all three: the remote server pushes the reply the moment it is ready (no poll delay, no repeated calls), and the wait happens in the triggerer rather than on a worker.

This Provider Design (Work flow)

sequenceDiagram
    autonumber
    participant DAG as DAG task<br/>(WebSocketSensor, deferrable=True)
    participant Worker
    participant Triggerer
    participant Server as Remote WebSocket server

    DAG->>Worker: execute()
    Worker->>Worker: self.defer(trigger=WebSocketTrigger(...))
    Note over Worker: TaskDeferred raised —<br/>state becomes DEFERRED,<br/>worker slot released immediately
    Worker->>Triggerer: hand off serialized WebSocketTrigger
    Triggerer->>Server: connect() + send(message_to_send)
    Note over Triggerer,Server: await recv() — no polling loop,<br/>no worker occupied while waiting
    Server-->>Triggerer: WebSocket message (e.g. job done)
    Triggerer-->>Worker: TriggerEvent(payload)
    Note over Worker: A worker is scheduled again<br/>only once the event fires
    Worker->>DAG: execute_complete(event)
Loading

The same WebSocketTrigger also backs the non-deferrable path (deferrable=False, the default): WebSocketSensor.poke() opens one connection directly on the worker and blocks on recv() up to the sensor's own timeout — no repeated reconnects, since a WebSocket message can only be read once and re-sending message_to_send could restart the remote job. mode="reschedule" is rejected for the same reason: a rescheduled attempt would need a brand-new connection anyway.

Because Airflow does not guarantee a trigger's run() executes only once (a triggerer restart or redistribution re-runs it from scratch), a reconnect there re-sends message_to_send too. If that message has a side effect on the remote server, such as starting a job, the server must treat a resend as safe — for example by deduplicating on a request id embedded in the message. This is documented on the trigger and sensor, and covered by a test that reconstructs a trigger from its own serialize() output and runs it twice.

Usage

Wait for a message, non-deferrable (holds a worker for the duration):

WebSocketSensor(
    task_id="wait_for_websocket_message",
    url="wss://example.com/socket",
    timeout=3,
)

Same thing, but deferrable — releases the worker while waiting:

WebSocketSensor(
    task_id="wait_for_websocket_message_async",
    url="wss://example.com/socket",
    deferrable=True,
    timeout=3,
)

Send a request over the connection right after it opens, then wait for the async reply — the common case where the remote server needs to be told what to do before it has anything to report back. url, header, and message_to_send are all templated, so a run id or auth token can come from the Dag context instead of being hard-coded:

WebSocketSensor(
    task_id="request_and_wait_for_websocket_reply",
    url="wss://example.com/socket",
    message_to_send='{"action": "start_job"}',
    header={"Authorization": "Bearer my-token"},
    deferrable=True,
    timeout=3,
)

Changes

  • Added WebSocketTrigger in airflow/providers/standard/triggers/websocket.py — a BaseTrigger (not BaseEventTrigger, which is the event-driven-scheduling marker and not meant for resuming a deferred task) that opens a ws:///wss:// connection with websockets.asyncio.client, optionally sends an initial message, and fires a TriggerEvent with the payload once a message is received.
  • Added WebSocketSensor in airflow/providers/standard/sensors/websocket.py, following the deferrable / poke() / execute() / execute_complete() pattern used by FileSensor. poke() shares one deadline between the connection handshake (open_timeout) and the message wait (recv(timeout=...)), and the sensor is @poke_mode_only since a rescheduled attempt can't reuse the connection anyway. url, header, and message_to_send are template fields, with a JSON renderer for header.
  • Registered both in provider.yaml (triggers and sensors sections); get_provider_info.py is regenerated from it via the update-providers-build-files prek hook rather than hand-edited, since that file is release-managed.
  • Added the websocket optional extra (websockets>=14.0) to pyproject.toml, plus the same dependency in the provider's dev group so tests run without installing the extra separately. uv.lock updated to match via update-providers-dependencies.
  • Added docs/sensors/websocket.rst (three usage examples plus the idempotency caveat above) and linked it from provider.yaml's how-to-guide list, following docs/sensors/file.rst.
  • Added the three examples above to example_dags/example_sensors.py, referenced by the docs page via exampleinclude.
  • Added unit tests for the trigger (serialization, message received, no-message-to-send, trigger-reconstruction resend) and the sensor (poke success/timeout, handshake timeout, remaining-time budgeting, reschedule-mode rejection, defer-without-poking-first, single-poke-per-timeout, template rendering) in tests/unit/standard/triggers/test_websocket.py and tests/unit/standard/sensors/test_websocket.py, using spec/autospec on all WebSocket mocks per the testing guidelines.

closes: #21139


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Sonnet 5)

Generated-by: Claude Code (Sonnet 5) following the guidelines

@ColtenOuO ColtenOuO left a comment

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.

self review.

Comment thread providers/standard/docs/sensors/websocket.rst

@ColtenOuO ColtenOuO left a comment

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.

self review.

Comment thread providers/standard/src/airflow/providers/standard/sensors/websocket.py Outdated
Comment thread providers/standard/tests/unit/standard/triggers/test_websocket.py Outdated
Comment thread providers/standard/tests/unit/standard/triggers/test_websocket.py Outdated
Comment thread uv.lock

@ColtenOuO ColtenOuO left a comment

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.

self review again.

Comment thread providers/standard/src/airflow/providers/standard/sensors/websocket.py Outdated
Comment thread providers/standard/src/airflow/providers/standard/sensors/websocket.py Outdated
Deferrable operators that hand a long-lived request off to a remote server
have no way to resume once that server replies over a WebSocket connection;
a plain HTTP request isn't well suited to holding a long-lived connection
open. This adds a generic WebSocketTrigger and WebSocketSensor to the
standard provider (not vendor-specific, alongside FileTrigger/FileSensor),
gated behind an optional `websocket` extra so the `websockets` dependency
isn't pulled in for users who don't need it.
The deferrable path polled once synchronously before deferring, which
consumes a WebSocket message and can resend message_to_send before the
trigger opens its own connection; the non-deferrable path fell through to
a second poke() after the sensor loop already succeeded, which could even
call self.defer() while deferrable=False. WebSocket reads are consumptive,
so each path must now touch the connection exactly once. WebSocketTrigger
also switches from BaseEventTrigger (the event-driven-scheduling marker)
to BaseTrigger, matching FileTrigger, since this trigger resumes a
deferred task rather than driving event-based scheduling. Also drops the
manual edit to the generated get_provider_info.py (provider.yaml is what
local/dev discovery reads; the release process regenerates the rest),
adds spec/autospec to the WebSocket mocks in both test files, and adds a
sample demonstrating message_to_send with header.
CI's dependency-sync check regenerates uv.lock and expects it to match
each provider's currently declared version; reverting the amazon/azure
entries to their stale values (per an earlier review comment) broke that
check, since pyproject.toml for both already declares the newer version.
Restoring the bump lets uv.lock agree with pyproject.toml again.
The prior fix only stopped execute() from polling a second time after
super().execute() already succeeded; the poke-mode retry loop it drives
still reconnected and re-sent message_to_send on every single poke, since
poke() opened a fresh connection each call. WebSocket messages are
consumptive, so repeatedly reconnecting can duplicate the remote request
and drop replies sent while no connection was open. poke() now opens the
connection once and reuses it across retries within the same task
attempt, and the sensor is marked poke_mode_only since that per-attempt
connection state would be lost under reschedule mode.
Regenerate get_provider_info.py via the update-providers-build-files
prek hook (the same one CI's static checks run) instead of leaving it
stale, which is what failed the "CI image checks / Static checks" job.

The prior fix kept a WebSocket connection open across pokes, but each
poke still only waited poke_interval before giving up, so
BaseSensorOperator's retry loop could still reconnect and re-send
message_to_send once poke_interval elapsed, and a poke_interval longer
than the sensor's timeout (e.g. the default 60s poke_interval against a
3s example timeout) could block past the declared timeout entirely.
poke() now opens exactly one connection and waits up to the sensor's
overall timeout, so execute() never needs a retry loop for this sensor.

Also documents that, like any Airflow trigger, WebSocketTrigger.run() can
execute more than once (triggerer restart or redistribution), so
message_to_send may be resent; a test demonstrates this by reconstructing
a trigger from its own serialize() output and running it twice. Adds
header and message_to_send to template_fields, since real requests
commonly need a run id or auth token resolved from the Dag context rather
than hard-coded in the Dag file.
@ColtenOuO
ColtenOuO force-pushed the websocket-trigger-standard-provider branch from 303cfe1 to d4ea6bf Compare August 27, 2026 11:57

@ColtenOuO ColtenOuO left a comment

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.

self review again, needs fixed PR description and CI

Comment thread providers/standard/src/airflow/providers/standard/sensors/websocket.py Outdated
Comment thread providers/standard/tests/unit/standard/sensors/test_websocket.py Outdated
Comment thread providers/standard/tests/unit/standard/sensors/test_websocket.py Outdated
poke() passed no open_timeout to connect(), so a slow handshake could add
websockets' 10s default on top of the recv() wait, letting a single poke
run well past the sensor's declared timeout. A handshake timeout also
raised outside the existing try block, bypassing soft_fail entirely
instead of being treated like any other sensor timeout. Both stages now
share one deadline: connect() gets the remaining time as open_timeout,
and recv() gets whatever is left after the handshake, with both timeouts
handled the same way.

Also adds autospec=True to the two remaining unspecced poke() mocks per
the testing guidelines, and adds "deduplicating" to the docs spelling
wordlist, fixing the CI static-checks docs spellcheck failure (the
existing entries only covered deduplicate/deduplicated/deduplication).

@ColtenOuO ColtenOuO left a comment

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.

One Nit, final review

@ColtenOuO
ColtenOuO marked this pull request as ready for review August 27, 2026 17:06
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.

Websocket trigger for long-lived deferable operators

1 participant