Add WebSocketSensor and WebSocketTrigger to the standard provider - #72133
Open
ColtenOuO wants to merge 6 commits into
Open
Add WebSocketSensor and WebSocketTrigger to the standard provider#72133ColtenOuO wants to merge 6 commits into
ColtenOuO wants to merge 6 commits into
Conversation
ColtenOuO
commented
Aug 27, 2026
ColtenOuO
commented
Aug 27, 2026
ColtenOuO
left a comment
Contributor
Author
There was a problem hiding this comment.
self review again.
2 tasks
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
force-pushed
the
websocket-trigger-standard-provider
branch
from
August 27, 2026 11:57
303cfe1 to
d4ea6bf
Compare
ColtenOuO
commented
Aug 27, 2026
ColtenOuO
left a comment
Contributor
Author
There was a problem hiding this comment.
self review again, needs fixed PR description and CI
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
commented
Aug 27, 2026
ColtenOuO
left a comment
Contributor
Author
There was a problem hiding this comment.
One Nit, final review
ColtenOuO
marked this pull request as ready for review
August 27, 2026 17:06
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.
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_intervalof 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 bypoke_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)The same
WebSocketTriggeralso backs the non-deferrable path (deferrable=False, the default):WebSocketSensor.poke()opens one connection directly on the worker and blocks onrecv()up to the sensor's owntimeout— no repeated reconnects, since a WebSocket message can only be read once and re-sendingmessage_to_sendcould 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-sendsmessage_to_sendtoo. 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 ownserialize()output and runs it twice.Usage
Wait for a message, non-deferrable (holds a worker for the duration):
Same thing, but deferrable — releases the worker while waiting:
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, andmessage_to_sendare all templated, so a run id or auth token can come from the Dag context instead of being hard-coded:Changes
WebSocketTriggerinairflow/providers/standard/triggers/websocket.py— aBaseTrigger(notBaseEventTrigger, which is the event-driven-scheduling marker and not meant for resuming a deferred task) that opens aws:///wss://connection withwebsockets.asyncio.client, optionally sends an initial message, and fires aTriggerEventwith the payload once a message is received.WebSocketSensorinairflow/providers/standard/sensors/websocket.py, following thedeferrable/poke()/execute()/execute_complete()pattern used byFileSensor.poke()shares one deadline between the connection handshake (open_timeout) and the message wait (recv(timeout=...)), and the sensor is@poke_mode_onlysince a rescheduled attempt can't reuse the connection anyway.url,header, andmessage_to_sendare template fields, with a JSON renderer forheader.provider.yaml(triggersandsensorssections);get_provider_info.pyis regenerated from it via theupdate-providers-build-filesprek hook rather than hand-edited, since that file is release-managed.websocketoptional extra (websockets>=14.0) topyproject.toml, plus the same dependency in the provider's dev group so tests run without installing the extra separately.uv.lockupdated to match viaupdate-providers-dependencies.docs/sensors/websocket.rst(three usage examples plus the idempotency caveat above) and linked it fromprovider.yaml'show-to-guidelist, followingdocs/sensors/file.rst.example_dags/example_sensors.py, referenced by the docs page viaexampleinclude.tests/unit/standard/triggers/test_websocket.pyandtests/unit/standard/sensors/test_websocket.py, usingspec/autospecon all WebSocket mocks per the testing guidelines.closes: #21139
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Sonnet 5) following the guidelines