kaxil opened a new pull request, #76270:
URL: https://github.com/apache/airflow/pull/76270

   `SqlSensor` has no deferrable mode. Waiting for a table to fill either holds 
a worker slot (`mode="poke"`) or starts the task on a worker again for every 
check (`mode="reschedule"`). This adds `deferrable=True`: the triggerer runs 
the query every `poke_interval`, and the task holds no worker slot while it 
waits. It works with any `DbApiHook` connection.
   
   ```python
   SqlSensor(
       task_id="wait_for_orders",
       conn_id="shop_db",
       sql="SELECT COUNT(*) >= %(min_orders)s FROM orders WHERE order_date = 
CURRENT_DATE",
       parameters={"min_orders": 3},
       poke_interval=60,
       deferrable=True,
   )
   ```
   
   Like other provider operators, `deferrable` defaults to `[operators] 
default_deferrable`. Prior art: `SnowflakeSensorAsync` in astronomer-providers 
is a deferrable `SqlSensor` for Snowflake only. A generic version in common.sql 
covers every `DbApiHook` backend.
   
   ![A deferred SqlSensor on airflow standalone: the triggerer polls until 
three orders land, then the downstream task runs](./sqlsensor-deferrable.gif)
   
   ## Design rationale
   
   **The triggerer checks the condition, not the worker.** The sensor checks 
once on the worker and returns straight away if the condition already holds. 
Otherwise it defers to a new `SqlSensorTrigger`, which sleeps `poke_interval`, 
runs the query, and fires once the first cell of the first row is truthy. With 
`fail_on_empty=True` it fails on an empty result instead. The task defers 
exactly once, and `timeout` is passed straight to `defer(timeout=...)`.
   
   **With `success`, `failure` or `selector`, the sensor does not defer.** 
These are Python callables from the Dag file, so the triggerer cannot run them. 
The alternative was a trigger that hands each row back to the worker to 
evaluate and then re-defers. For the usual shape, `SELECT COUNT(*)` with 
`success=lambda n: n >= 100`, that starts the task on a worker for every check, 
which costs as much as `mode="reschedule"` plus a trip through the triggerer. 
So when any of them is set, the sensor logs a warning and runs in its usual 
`mode`. `HttpSensor` does the same when `response_check` is set. The docs tell 
users to write the condition in SQL instead.
   
   The sensor also logs a warning and stays in its usual `mode` when:
   - **`silent_fail`, `never_fail` or `exponential_backoff` is set.** They only 
take effect inside the poke loop of `BaseSensorOperator.execute`.
   - **A subclass overrides `poke`.** The triggerer only checks the first cell, 
so it would skip the subclass's condition and succeed too early.
   - **It runs on Airflow 3 before 3.2.2.** Each poll, connection lookup 
included, runs in a thread (`sync_to_async(thread_sensitive=False)`) so a slow 
query never blocks other triggers. Before 3.2.2, sync SDK calls from several 
trigger threads at once can make the TriggerRunner read supervisor responses 
out of order and crash. #66412 fixed that, and it was backported to 3.2.2. 
Airflow 2 has no comms channel, so 2.11 defers too.
   
   **`SQLExecuteQueryTrigger` gains an optional `parameters` argument.** It is 
passed to `get_records` and serialized. `SqlSensorTrigger` subclasses it to 
reuse `get_hook` and `serialize`. Apart from the new argument, 
`SQLExecuteQueryTrigger` and `GenericTransfer`, which uses it, are unchanged.
   
   **The trigger builds the hook and calls `get_records` on every poll.** That 
is the same call the worker-side `poke()` makes. It picks up credentials 
rotated during a long wait, and runs the identical query on hooks that override 
only `get_records` (Hive passes `parameters` as `hive_conf` there).
   
   ## Gotchas
   
   - On Airflow 2.11, a deferral that hits `timeout` fails with a retryable 
`TaskDeferralError`, not `AirflowSensorTimeout`, so retries restart the wait. 
That is core 2.x behaviour for every deferrable sensor.
   - `soft_fail=True` turns any trigger failure into a skip, including 
`fail_on_empty`, because `BaseSensorOperator.resume_execution` converts every 
`AirflowException`. An error on the first worker-side check follows poke-mode 
rules instead.
   
   ## Verified end to end
   
   I ran this on `airflow standalone` from this branch (main, Postgres 
backend), with a real scheduler, triggerer and worker, and the sensor reading a 
Postgres `shop` database:
   
   | Dag | What happened |
   |---|---|
   | `wait_for_orders` | Task went to `deferred`; the triggerer polled every 10 
s ("Criteria not met"). I inserted three orders, the next poll fired, the 
worker logged "Success criteria met", and `process_orders` ran. |
   | `wait_for_orders_callable` (`success=lambda count: count >= 3`, 
`mode="reschedule"`) | Logged the fallback warning and went to 
`up_for_reschedule` without deferring. |
   | `wait_for_orders_timeout` (`timeout=30`, condition never met) | The 
triggerer polled every 5 s and logged "Trigger cancelled due to timeout". The 
task failed with `AirflowSensorTimeout` on try 1, 33 s after it started. |
   
   On Airflow 2.11.1, with wheels built from this branch and a real sqlite 
connection, the trigger survived a `serialize()` round trip, polled in a thread 
until the condition held, and the sensor succeeded on resume. The callable 
sensor poked on the worker. A `fail_on_empty` event raised, or skipped under 
`soft_fail`.
   
   ![Deferred: the worker's first check, then the triggerer 
polling](./deferred-task-log.png)
   ![Success after the orders land](./success-task-log.png)
   ![Fallback warning when success is set](./callable-fallback-log.png)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to