Repository navigation
Fill scheduler task batches after concurrency rejection - #70307
hussein-awala merged 6 commits into
Conversation
|
Here is my humain's formulation of the problem: In our production environment, we had a problem where some tasks stayed in is_done = executable_tis or len(task_instances_to_examine) < max_tisTo our understanding, This PR attempt to solve this by making sure the scheduler loop continues to check the next batch of schedulable tasks: if len(executable_tis) >= max_tis or not found_new_filters: |
d81660a to
eac3bf9
Compare
|
Commit |
|
Tested it live in our Airflow deployed instances and got the following logs: Stating that we reached this line |
|
This has been in our production datacenter for the last 2 weeks, and we haven't experienced any regression so far. Still looking for a review |
69610e6 to
400ce59
Compare
|
Hi @potiuk / @hussein-awala 👋 |
potiuk
left a comment
There was a problem hiding this comment.
Thanks for the detailed write-up and the production data. The diagnosis is right. On main, is_done = executable_tis or ... ends the selection loop as soon as a page yields a single executable TI, so a page dominated by concurrency-blocked higher-priority TIs queues one TI per scheduler loop. Re-querying with the learned starvation filters and excluding the already-selected IDs is the right fix, and so is moving executor_slots_available out of the per-page loop.
A few things before this can go in:
- Rebase. #66878 moved the query into
_build_schedulable_tis_query(...), so the selected-ID exclusion needs to become a parameter there, applied before therow_number()window as you have it now. - Bound the refill loop. Termination is guaranteed, but the worst-case number of iterations now grows with
max_tis(up to roughlymax_tisrefills when each page yields one runnable TI). All of them run inside the critical section, holding the pool row locks and, on Postgres, the advisory lock, withNOT INlists that keep growing. Withmax_tis_per_query=0that can be hundreds of queries while other HA schedulers getcritical_section_busy. Please add a small hard cap on refill iterations, and log at debug or emit a counter when it is hit. - Tests for the two new invariants. Neither is covered right now:
- executor slots consumed across iterations, e.g.
slots_available=2, page 1 = blocked + 1 runnable, page 2 = 2 runnable → exactly 2 queued. Moving the block back inside the loop currently passes the whole suite. - the in-page
if len(executable_tis) >= max_tis: break: make page 2 hold more runnable TIs than the remaining capacity and assert the batch never exceedsmax_tis. In the current test the batch fills on the last row of page 2, so deleting that break still passes.
- executor slots consumed across iterations, e.g.
- Small: asserting
session.scalars.call_count == 2ties the test to unrelated internals. The queued-set assertion is the important one. Please also update the PR description, which still describes theLIMIT 1refill.
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
d9328d1 to
bbf3c17
Compare
|
Thanks for the review. All four points are addressed:
A separate test patches the cap to 1, expects the batch to stop after one refill, and checks the counter. One more behaviour change surfaced while writing the tests. |
|
The test |
|
Thanks @hussein-awala for your review. You were indeed correct, and I updated my PR to account with this specificity. I added your test, reproduced the failure before my changes, and my new changes now make the test passes. |
hussein-awala
left a comment
There was a problem hiding this comment.
Thanks for addressing the review feedback. The run-scoped exclusions fix the priority issue I raised, and the refill cap and regression tests cover the other concerns. LGTM, pending green CI.
One performance consideration: on main, the scheduler can already keep querying without a cap until it finds a runnable task, provided each page is full. This PR also allows that scanning to continue after short pages when new filters can expose runnable candidates.
That can increase critical-section duration for blocked-heavy workloads, although refills after the first selection are capped at three. This isn’t a blocker for me.
@ashb @uranusjr could you also take a look, particularly at the refill behavior and its impact on the scheduler’s critical section?
There was a problem hiding this comment.
🔵 Needs a closer look
Refills extend the scheduler’s lock-holding critical section, warranting human review and PostgreSQL validation after the rebase.
0 open findings
What changed in this PR
Improves scheduler throughput by refilling task batches when concurrency-blocked candidates leave capacity unused.
Changes:
- Continues selection while preserving executor capacity and excluding already-selected tasks.
- Scopes starvation filters to individual Dag runs and caps refill queries with a new metric.
- Adds regression tests for batch limits, priority ordering, and blocked candidates.
| File | Description |
|---|---|
| shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml | Registers the refill-limit counter. |
| airflow-core/tests/unit/jobs/test_scheduler_job.py | Tests refill behavior and scheduling constraints. |
| airflow-core/src/airflow/jobs/scheduler_job_runner.py | Implements bounded refills and Dag-run-specific exclusions. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A candidate query could stop after selecting any executable task, leaving remaining capacity unused when higher-priority candidates were rejected by in-memory concurrency checks. This could repeatedly starve lower-priority tasks.
When a candidate batch is nearly full, reducing the query limit to the remaining capacity can require one database round trip for every distinct blocked task.
2880099 to
5c08377
Compare
|
All the comments were addressed, I will merge. Thanks @avolant for the fix! |
Why
We observed many runnable task instances remaining in the
SCHEDULEDstate for a long time. Higher-priority task instances repeatedly appeared ahead of them in the candidate query but could not be queued because of Dag-internal concurrency limits. Those blocked candidates consumed the query limit and delayed lower-priority runnable tasks.The candidate query applies
LIMIT max_tis, then runs concurrency and capacity checks in memory. Onmain, the selection loop stops as soon as a page yields one executable TI, or when the page is shorter thanmax_tis. A page dominated by blocked TIs therefore queues one TI per scheduler loop, and the same blocked TIs lead the next loop's page again.For example, with
max_tis=2and candidates ordered as:The first query returns
[A, B]. The scheduler rejectsA, acceptsB, and stops with one of two positions filled.Cwaits for the next scheduler loop.What changed
max_tisand the previous query added a starvation filter. In the example, the second query excludesA's task through the learned filter andBby ID, returns[C], and the batch becomes[B, C]._build_schedulable_tis_querytakes a keyword-onlyexcluded_ti_ids, applied before themax_active_tasksrow-number window like the starvation filters. Selected TIs staySCHEDULEDin the database until the loop marks themQUEUED, so later queries would otherwise return them again.LIMIT max_tis, and selection stops inside the page once the batch is full. Shrinking the limit to the remaining capacity would cost one round trip per blocked task when the batch is nearly full.MAX_TI_REFILL_QUERIES_PER_LOOP = 3. They run inside the critical section, holding the pool row locks and, on PostgreSQL, the advisory lock. Hitting the cap logs at debug level and increments the newscheduler.critical_section_refill_limit_reachedcounter. Queries before the first selection stay uncapped, as onmain. Capping them would let a long enough run of blocked candidates starve runnable TIs indefinitely, because every scheduler loop starts with empty filters.max_active_taskswindow can return only its blocked TIs and hide its runnable ones until the blocked TIs are filtered out.This intentionally does not use SQL
OFFSET. Each query adds newly learned filters, which changes and shrinks the ordered result set, so an offset could skip valid candidates.Validation
New tests share one scenario where each three-row candidate page holds a single runnable TI until the last page:
test_select_task_instances_to_queue_refills_batch_after_concurrency_rejectionfills the batch to exactlymax_tiswhen the last page has more runnable TIs than free positions. A second case with two executor slots queues exactly two TIs, so slots are shared across queries.test_select_task_instances_to_queue_stops_at_refill_query_limitpatches the cap to 1, checks the batch stops after one refill, and checks the counter is emitted.test_select_task_instances_to_queue_finds_runnable_hidden_by_max_active_tasks_windowcovers the short-page case.Each test fails when its invariant is removed: rebuilding executor slots per query, dropping the in-page
max_tisbreak, removing the cap, dropping the selected-ID exclusion, or restoring the short-page exit. All four fail onmain.test_scheduler_job.pyon SQLite: 503 passed, 4 skipped.prekpre-commit and manual stages on the changed files, includingmypy-airflow-coreand the metrics registry check: passed.Was generative AI tooling used to co-author this PR?
Generated-by: Codex (GPT-5) and Claude Code (Opus 5.5) following the guidelines