hussein-awala opened a new pull request, #76706:
URL: https://github.com/apache/airflow/pull/76706

   > **Context.** #76463 fixes, for 3.4.x, the starvation of pending 
partition-driven Dag runs without a migration: the scheduler rotates through 
every pending run instead of re-checking the 500 oldest. It still evaluates 
every pending run again and again, whether or not anything changed for it, 
which is acceptable as a stopgap for a patch release but not as the design. 
This PR is the 3.5.0 design that #76463 is the stopgap for: the scheduler 
evaluates a pending run only when something happened that can change its 
result. It is based on `main` without #76463 for now; see "Relation to #76463" 
below.
   
   On `main`, each scheduler loop locks the 500 oldest pending partition-driven 
Dag runs (`AssetPartitionDagRun`) and evaluates them, whether or not anything 
changed for them:
   - Runs that keep waiting by design, such as a rollup whose window is not 
complete, fill those 500 slots, and newer runs whose partitions are complete 
are never evaluated (since 3.3.0, #64571). #76463 fixes that.
   - The work is the same on an idle deployment: the claim costs 128 ms per 
loop on PostgreSQL with 55,000 pending runs, and each loop evaluates up to 500 
runs and decodes the serialized Dags they belong to.
   - On MySQL the claim locks every pending row, not 500, so an HA peer finds 
nothing to claim.
   
   A natural alternative is for writers to flag the runs whose inputs changed. 
A flag on the run row, though, would be an UPDATE inside the producer's 
task-success transaction, while the execution API holds the task instance's row 
lock and while the scheduler may hold the run's row: the producer would wait on 
the scheduler, and a lock timeout would fail the producer task. This PR has the 
writer insert a request instead, which never waits.
   
   ### How
   
   **A request queue.** The new table `asset_partition_dag_run_wake` (migration 
0157) holds requests to evaluate pending runs: `id`, `apdr_id` (NULL for a 
request about every pending run of a Dag), `target_dag_id`, `reason` and 
`created_at`. It has no foreign key and no unique key, so an insert never waits 
on the scheduler's lock of the run and never conflicts with a request the 
scheduler already read. Rows are never updated: writers insert them, and the 
scheduler deletes the ones it read, by primary key.
   
   **Who writes requests**, each in the transaction of the change it reports:
   - **Key logs.** An ORM listener writes one request for every 
`PartitionedAssetKeyLog` that a session flush inserts. This covers the 
execution API, the REST API, the triggerer and any `[core] asset_manager_class` 
that adds key logs to the session, without changing them.
   - **Definitions.** `SerializedDagModel.write_dag` writes one request for the 
whole Dag when the timetable section of the definition changes, for a 
partitioned Dag or a Dag that still has pending runs. The timetable holds the 
asset condition and the partition mappers; nothing else in the definition 
changes an evaluation.
   - **Asset activation.** The scheduler (`_activate_referenced_assets`) and 
the Dag processor (`activate_assets_if_possible`) write one request for each 
Dag scheduled on an asset they activate, because key logs of an inactive asset 
do not count.
   
   **What a scheduler loop evaluates**, within the same budget of 500 runs per 
loop:
   - **Requested runs**: oldest request first within a Dag, and Dags in turn. 
On an idle scheduler this read is the only query of the claim.
   - **Fan-outs**: for a request about a whole Dag, every pending run of the 
Dag, read once and spread over the next loops.
   - **Resync passes**: every pending run, once when the scheduler starts and 
then once every `[scheduler] asset_partition_resync_interval` (new, 3600 s by 
default). They catch what no request reports: key logs written by components 
that still run an older version during an upgrade, key logs written with a bulk 
insert, and a new link from an alias to an asset that is already active.
   
   While the fan-outs or a pass have runs left, each keeps a tenth of the loop.
   
   **Claim.** As in #76463:
   - Runs are locked by primary key with `FOR UPDATE SKIP LOCKED` and `main`'s 
paused, draining and stale filter, with up to 3 refills.
   - Satisfied runs fire oldest first within a loop.
   
   **Request lifecycle.**
   - **No lost requests.** A loop deletes only requests that it read before the 
evaluation that answers them: a run's requests in the loop that evaluates the 
run, and a Dag's in the loop that finishes its fan-out. It reads them before 
the serialized Dag and the key logs, so a request written during the evaluation 
survives and asks again, under READ COMMITTED on PostgreSQL and MySQL; SQLite 
has one writer.
   - **Retries.** Fan-out and pass progress lives in memory and is kept only 
once the loop commits, so a loop that `@retry_db_transaction` retries does it 
again.
   - **Cleanup.** The claim drops requests whose run fired or was deleted. A 
timer on `[scheduler] parsing_cleanup_interval` drops the requests of Dags that 
no longer exist. It also merges those of paused, draining or stale Dags into 
one request per Dag, in batches committed on their own, so the queue the claim 
reads stays small.
   
   **Metrics:**
   - `scheduler.asset_partitions.wake_lag`: the age of the oldest waiting 
request of a Dag that can run (not paused, draining or stale).
   - `scheduler.asset_partitions.evaluated`: the runs evaluated.
   - `scheduler.asset_partitions.resync_fired`: runs created that only a pass 
found. It is tagged `startup` or `periodic`. A periodic count above 0 means 
that a change reached no request: key logs written without one, an alias newly 
linked to an asset that was already active, or custom code whose result changes 
while its inputs do not.
   - `scheduler.asset_partitions.resync_pass_duration`.
   
   ### Behaviour changes
   
   - **Evaluation follows requests.** A pending run that nothing asks for is 
evaluated by the next pass. A pass starts `asset_partition_resync_interval` 
after the previous one started, or when that one ends if it took longer.
   - **Contract for custom code.** Evaluation must depend only on the 
timetable, the run's key logs, which assets are active, and how asset 
references and aliases resolve. Custom partition mappers, windows and 
timetables must keep to this: a mapper that reads the clock, or fails now and 
then, is evaluated again only by the next pass. Key logs inserted in bulk 
rather than through a session wait for the pass too.
   - **Errors.** A run whose evaluation fails because of the Dag itself 
(deserialization, fingerprint, a mapper that raises) is evaluated again only 
when something asks for it (a new key log, a timetable change, an asset 
activation) or a pass runs, rather than on every loop. A failing 
`create_dagrun` still fails the loop, as on `main`.
   - **Upgrade.** No backfill: each scheduler's startup pass evaluates the runs 
that were pending at the upgrade. Components that still run 3.4 write no 
requests, so upgrade the schedulers after the other components, or restart them 
once every component runs 3.5.
   - **Order.** Within a loop, runs fire oldest first, as before. Across loops, 
requested runs come before the others: oldest request first within a Dag, Dags 
in turn.
   - **Dags no longer partitioned.** Their pending runs are dropped even when 
the fingerprints match. A Dag scheduled only on aliases has an empty 
fingerprint either way, and evaluating its runs under a timetable that is not 
partitioned raises on `main`.
   - **Decoded definitions.** `SerializedDagModel` drops its decoded data when 
the instance is expired, as on a rollback (Airflow's sessions do not expire on 
commit), so a loop retried after a definition was written in place evaluates 
the new one.
   
   ### Relation to #76463
   
   - **Purpose.** #76463 (milestone 3.4.1) is the stopgap for 3.4.x: no 
migration, and every pending run evaluated in rotation. This PR replaces that 
rotation in 3.5.0: evaluation follows requests, and a slow pass is the safety 
net.
   - **Overlap.** Both PRs change `_create_dagruns_for_partitioned_asset_dags`, 
`metrics_template.yaml`, `assets.rst` and a comment in the UI's assets route, 
so whichever lands second rebases. If #76463 lands first, the rebase removes 
its per-loop rotation. Its metrics (`pass_loops`, `pass_duration`, 
`oldest_pending_age`) are then either removed or kept for the resync pass. 
#71072, which logs when the per-loop cap is reached, also changes that function.
   - **Not in this PR:**
     - isolating a failing fire, so that one bad run cannot fail the loop;
     - showing the request state in the UI;
     - handling key logs that arrive while their run fires (pre-existing).
   
   ### Open questions for reviewers
   
   1. The resync interval's default (1 hour), which bounds the delay for 
requests that nothing writes.
   2. A request table, as here, or a marker column on the key log. A marker 
needs no listener and also covers writers outside the ORM, but costs an UPDATE 
per key log on the largest partition table and an index over it.
   3. Whether this change wants a design discussion on the dev list first.
   
   ### Testing
   
   On the final commit:
   - **SQLite:** the changed test files and the rest of 
`tests/unit/migrations`, 1,623 passed; `test_serialized_dag.py`, 114 passed.
   - **PostgreSQL 16:** the partition tests of `test_scheduler_job.py`, 100 
passed; the other changed test files except `test_db_cleanup.py`, 278 passed.
   - **MySQL 8.0:** the partition tests, 99 passed, with the one 
PostgreSQL-only test skipped; the other changed test files except 
`test_db_cleanup.py`, 278 passed.
   - **`breeze verify`:** the core DB tests (13,009 tests in 6 types, no 
failures), the core non-DB tests (5,718 passed), the `standard` provider suites 
(DB and non-DB), the `common.compat` suite (non-DB; it has no DB tests), and 
`breeze build-docs apache-airflow`. `breeze verify` also lists every provider 
suite, because `devel-common` changed: before each provider DB test module, the 
`_clear_db` fixture runs `clear_db_apdr()`, which this PR changes. The 
`standard` DB suite above ran it; CI runs the other suites, including the runs 
against older Airflow versions, which have no `AssetPartitionDagRunWake`.
   - **prek:** the pre-commit and manual stages, with the migration round trip.
   - **Mutations:** 54 changes to the code, each of which breaks one rule of 
this PR, are each caught by the test for that rule.
   - **Order:** the partition tests also pass in reverse order and in 18 
shuffled orders.
   
   Existing tests that change:
   - `test_partitioned_dag_run_resumes_when_asset_reactivates`: an 
`AssetActive` row that something other than the scheduler or the Dag processor 
inserts now waits for the next pass.
   - `test_partition_cap_at_exactly_n_processes_all` and 
`test_partition_cap_at_n_minus_one_leaves_one_pending`: the order within a Dag 
now comes from the requests, so the `created_at` pin of the second test is 
gone, and both docstrings name the per-loop budget.
   - `test_pending_apdr_select_locks_rows_for_skip_locked_claim`: the claim no 
longer runs a locking query when it has no run to evaluate, so the test creates 
a pending run.
   - `TestNextRunAssets::test_pending_apdr_uses_fifo_order` (UI), docstring 
only: the pending run that waited longest is not necessarily the next to fire.
   - `test_no_models_missing`: the request table is not part of `airflow db 
clean`; the scheduler deletes its rows.
   - `test_pending_apdr_select_breaks_tie_on_id` is removed. It ran its own 
`SELECT ... ORDER BY created_at, id`, which stood for the claim query that this 
PR removes, and called no scheduler code.
   
   Not from this PR: on `main` too, `test_serialized_dag.py` fails after 
`test_scheduler_job.py` in one pytest process unless the database was just 
initialized, because an existing scheduler test fills the plugin cache while 
`load_examples` is off. The uv runs above keep the two files in separate 
processes; the breeze run initializes the database first.
   
   ### Cost of the claim
   
   Measured as [the guide for performance-sensitive 
components](https://github.com/apache/airflow/blob/main/.agents/skills/airflow-publish-changes/references/performance-sensitive-components.md)
 describes: `main`'s claim (one locking `SELECT ... ORDER BY created_at, id 
LIMIT 500`) against this PR's claim, in alternating rounds with a server-side 
control query. The numbers are medians of 6 rounds of 10 loops, each rolled 
back; the control drifted at most 2.5%. Each database holds about 1,050,000 
partition-driven Dag runs. Of them, 55,000 (PostgreSQL) and 50,101 (MySQL) are 
pending, in about 2,300 and 2,400 Dags, and 50,000 and 45,075 belong to Dags 
that are not paused, draining or stale.
   
   | Claim per loop | PostgreSQL 16 | MySQL 8.0 |
   |---|---|---|
   | `main` | 128–130 ms | 95–100 ms |
   | No request (idle) | 4.5 ms, 1 statement | 4.2 ms, 1 statement |
   | 50 requests | 9.7 ms | 10.7 ms |
   | 500 requests | 18.7 ms | 19.7 ms |
   | A request for every pending run of a Dag that can run (50,000 and 45,075) 
| 97 ms | 134 ms |
   
   - On MySQL, a large backlog of requests costs more than `main`'s claim: a 
third more with a request for every pending run of a Dag that can run, because 
the signal read groups every request in a temporary table. The backlog drains 
by up to 500 runs per loop, while `main` pays its cost on every loop.
   - These numbers are the claim only. On `main`, each loop also evaluates up 
to 500 runs; here, only the runs that something asked for, plus every pending 
run once in each resync pass.
   - A resync pass reads every pending run once: about 0.5 s on PostgreSQL for 
55,000 runs and 0.6–0.7 s on MySQL for 50,101 runs, with a peak of about 
120–130 bytes of Python memory per pending run.
   - Starting a fan-out reads the requests and the pending runs of one Dag: 3–5 
ms on PostgreSQL for its largest Dag (528 runs) and 9–18 ms on MySQL for its 
largest Dag (4,270 runs).
   
   related: #76463, #64571
   
   ---
   
   ##### Was generative AI tooling used to co-author this PR?
   
   - [X] Yes (please specify the tool below)
   
   Generated-by: Claude Code (Opus 5.5) following [the 
guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   


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