edumuellerFSL opened a new issue, #71172:
URL: https://github.com/apache/airflow/issues/71172

   
   ### Under which category would you file this issue?
   
   Airflow Core
   
   ### Apache Airflow version
   
   3.2.2 (checked against 3.3.0 and `main`, unchanged)
   
   ### What happened and how to reproduce it?
   
   A sensor running with `mode="reschedule"` is marked failed by the scheduler 
with a state mismatch, where the executor event being processed belongs to the 
previous poke of the same try.
   
   To be clear about the word "reschedule", since it is overloaded here: this 
is not the defer path. #66431 is titled "ignore stale executor success after 
defer reschedule", where reschedule means the scheduler re-queued a deferred 
TI. This report is about `BaseSensorOperator(mode="reschedule")`, where the 
task exits `up_for_reschedule` and is re-queued for the next poke. Same shape 
of race, different path, and the existing branch does not cover it.
   
   Timeline of one occurrence (UTC, `poke_interval=60`, CeleryExecutor):
   
   ```
   00:04:02.121  scheduler   TI queued (try_number=1, queued_by_job_id=J)
   00:04:02.838  worker      Executing workload in Celery
   00:04:03.003  api-server  Task started (previous_state=queued)
   00:04:07.339  api-server  Task instance state updated  
new_state=up_for_reschedule
   00:04:07.466  worker      Task finished  exit_code=0  
final_state=up_for_reschedule
                             (celery task returns normally, result is SUCCESS)
   00:05:07.338  (db)        task_reschedule.reschedule_date for the next poke
   00:05:33.399  scheduler   TI put back to scheduled for the next poke
   00:05:36.901  scheduler   executor success from the 00:04:07 poke is drained
   00:05:36.914  scheduler   ERROR state mismatch, task marked FAILED
   ```
   
   The poke itself exited cleanly (`exit_code=0`, 
`final_state=up_for_reschedule`). Its executor success was not drained until 
89s later, against a 60s `poke_interval`, by which point the scheduler had 
already put the same TI back to `scheduled` for the next poke 3.5s earlier.
   
   The scheduler line immediately before the error, which shows both sides:
   
   ```
   TaskInstance Finished: dag_id=..., task_id=..., run_id=..., ti_id=...,
   run_start_date=... 00:04:02.935, run_end_date=... 00:04:07.339, 
run_duration=4.403,
   state=scheduled, executor=CeleryExecutor(parallelism=512), 
executor_state=success,
   try_number=1, max_tries=0, operator=ExternalTaskSensor,
   queued_dttm=... 00:04:02.121, scheduled_dttm=... 00:05:33.399, 
queued_by_job_id=J
   ```
   
   Notice `scheduled_dttm` (00:05:33.399) is earlier than the moment the event 
was processed (00:05:36.901), and `try_number` is the same on both sides. And 
the resulting error:
   
   ```
   Executor CeleryExecutor(parallelism=512) reported that the task instance
   <TaskInstance: <dag_id>.<sensor_task_id> scheduled__... [scheduled] 
ti_id=...>
   finished with state success, but the task instance's state attribute is 
scheduled.
   ```
   
   **Why the current `ti_requeued` branch does not cover this**
   
   
https://github.com/apache/airflow/blob/e69c1881b32c36abb827bae3717eaf46424427bd/airflow-core/src/airflow/jobs/scheduler_job_runner.py#L1529-L1539
   
   In reschedule mode `try_number` does not increment between pokes, so poke N 
and poke N+1 share an executor key, `ti.try_number == buffer_key.try_number` 
holds and `ti_queued` is True.
   
   All three conditions in `ti_requeued` are False for a reschedule-mode sensor:
   
   - `ti.queued_by_job_id != job_id` is False when the same scheduler that 
queued poke N also re-queues poke N+1. With 8 schedulers it is usually a 
different job id, which is why this fires intermittently rather than on every 
poke.
   - `executor.has_task(ti)` is False. The reschedule date has passed and the 
TI is back in `scheduled`, but it has not been handed to the executor yet.
   - the resume-after-defer condition is False, because `ti.next_method` is 
`None` on a reschedule exit. It is only set when resuming from a trigger.
   
   So it falls through to `if ti_queued and not ti_requeued` and the task is 
failed.
   
   Checked against `main` (e69c188) and 3.3.0. On `main` the resume-after-defer 
condition was widened to cover `QUEUED` as well as `SCHEDULED`, but it is still 
gated on `ti.next_method is not None`. Nothing in `ti_requeued` references 
`TaskReschedule` or `up_for_reschedule`.
   
   Conditions that have to line up:
   
   1. a sensor with `mode="reschedule"`
   2. executor event drain latency greater than `poke_interval`
   3. the same scheduler processes both the re-queue and the stale event
   4. the TI is in `scheduled` (or `queued`) when the stale success is drained
   
   **Reproducer (probabilistic)**
   
   Same caveat as #66374 and #67287: this is a race and is not 
deterministically reproducible.
   
   1. Airflow 3.2.x or later, any executor whose event buffer can lag.
   2. A sensor with `mode="reschedule"` and a short `poke_interval` (60s here) 
whose condition stays unmet for many pokes.
   3. Put the scheduler under enough load that draining the executor event 
buffer takes longer than `poke_interval`.
   4. Watch the audit log for the `[scheduled]` vs `success` pair on the sensor 
task.
   
   We see it on CeleryExecutor, but nothing in the mechanism is 
Celery-specific: it only needs the executor event for poke N to arrive after 
the scheduler has re-queued poke N+1. #66374 hit the analogous defer-path race 
on CeleryExecutor and #67287 hit it on LocalExecutor, so the family already 
spans both.
   
   **Differences and linkages with current tickets**
   
   - #23824 / #23846 (CLOSED): the original 2.x race between triggerer and 
scheduler, fixed by "Do not fail requeued TIs". Same underlying shape, defer 
path.
   - #66374 (CLOSED): the 3.x scheduled-state variant of the defer path. Fixed 
by #66431, backported to `v3-2-test` by #67089.
   - #67287 (CLOSED): the queued-state variant of the same defer path, which 
#66431 did not cover. Fixed by widening the condition to `ti.state in 
(SCHEDULED, QUEUED)`.
   - This report: the reschedule-mode sensor path. Both existing fixes are 
gated on `ti.next_method is not None`, which is never true for a reschedule 
exit, so no existing condition applies. The state pair and the failure mode are 
otherwise identical to #66374.
   
   ### What you think should happen instead?
   
   A stale executor success belonging to a completed reschedule poke should be 
treated as a requeue, the same way the defer-exit case now is, rather than as 
an externally killed task.
   
   The distinguishing signal is the presence of a `TaskReschedule` row for the 
TI, which is what tells the scheduler this task exited `up_for_reschedule` at 
least once rather than being killed:
   
   ```python
   ti_requeued = (
       ti.queued_by_job_id != job_id
       or executor.has_task(ti)
       or (
           # Resume-after-defer (existing)
           ti.state in (TaskInstanceState.SCHEDULED, TaskInstanceState.QUEUED)
           and state == TaskInstanceState.SUCCESS
           and ti.next_method is not None
       )
       or (
           # Stale reschedule-exit: the poke exited up_for_reschedule and the 
next poke was
           # already re-queued before we drained the executor success for the 
same try_number.
           ti.state in (TaskInstanceState.SCHEDULED, TaskInstanceState.QUEUED)
           and state == TaskInstanceState.SUCCESS
           and ti.next_method is None
           and ti.id in ti_ids_with_reschedule
       )
   )
   ```
   
   Two notes on that:
   
   The `TaskReschedule` lookup is what keeps the condition narrow. Simply 
dropping the `next_method is not None` requirement would also swallow genuine 
external kills of `scheduled` TIs, which is the case this branch exists to 
catch.
   
   `TaskReschedule` is not currently referenced in `scheduler_job_runner.py`, 
and doing a lookup per event would add a query to the hot path. It should be 
resolved once for the TIs in the buffer, alongside the existing bulk TI fetch 
in `_process_executor_events`, rather than per event. Happy to shape it 
whichever way maintainers prefer.
   
   ### Operating System
   
   Debian GNU/Linux 12 (bookworm), official 
`apache/airflow:slim-3.2.2-python3.12` image
   
   ### Deployment
   
   Official Apache Airflow Helm Chart
   
   ### Apache Airflow Provider(s)
   
   _No response_
   
   ### Versions of Apache Airflow Providers
   
   apache-airflow-providers-celery==3.20.0
   apache-airflow-task-sdk==1.2.2
   
   ### Official Helm Chart version
   
   1.21.0
   
   ### Kubernetes Version
   
   v1.34.2
   
   ### Helm Chart configuration
   
   CeleryExecutor, 8 scheduler replicas, `core.parallelism=512`, 
`celery.worker_concurrency=16`, `celery.task_acks_late=false`. No other 
scheduler-related overrides.
   
   ### Docker Image customizations
   
   Official slim image plus in-house provider packages installed with `uv`. No 
changes to `airflow-core`; the scheduler is stock.
   
   ### Anything else?
   
   25 occurrences over 7 weeks in one deployment, across roughly 13 different 
DAG authors. Every one is a sensor in `mode="reschedule"`; no other operator 
type produced this state pair.
   
   Frequency tracks scheduler and metadata database latency rather than 
anything in the DAGs. On the worst day, when the metadata DB was CPU-saturated 
by an unrelated long-running transaction, 5 of the 25 landed within that day.
   
   The affected tasks run with `max_tries=0`, so a single stale event ends the 
DAG run rather than costing a retry.
   
   ### Are you willing to submit PR?
   
   - [x] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's [Code of 
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
   


-- 
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