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

   ### Summary
   
   `SchedulerJobRunner._remove_unreferenced_triggers` issues an **unbounded 
single-statement `DELETE` against the `trigger` table every 
`parsing_cleanup_interval` (default 60s)**. On any deployment where the trigger 
table grows large — which is expected over time, since deferrable operators and 
asset/asset-alias watchers each leave a trigger row behind — this holds row 
locks for the entire transaction and stalls concurrent writers on the same 
table, including the triggerer's own `Trigger.clean_unused()` / `submit_event` 
paths. It also forces a full table materialisation in Python via 
`synchronize_session="fetch"` before the DELETE runs.
   
   ### Why this matters
   
   - **AGENTS.md (Coding Standards) is explicit:** *"Bulk `DELETE`/`UPDATE` in 
the scheduler loop or any synchronous interval task (e.g. 
`call_regular_interval` callbacks) must be batched with `LIMIT` and committed 
between batches — never issue a single unbounded bulk write against a 
user-driven table. … Follow the batching pattern in 
`airflow-core/src/airflow/utils/db_cleanup.py`."* The current implementation 
predates and contradicts that rule. Affected column joins 
(`task_instance.trigger_id`, `asset_watcher.trigger_id`, `callback.trigger_id`) 
are already indexed.
   - **Two cleanup paths, one batched and one not.** #70961 (merge commit 
`c3cc5573b8`) moved the **triggerer's** version (`Trigger.clean_unused()`) onto 
the existing pattern at `models/trigger.py:_delete_unused_batch`: `select id 
LIMIT, with_row_locks(skip_locked=True), delete by id, commit`, driven by 
`[triggerer] unreferenced_triggers_cleanup_batch_size`. The scheduler-side 
counterpart was missed by that change — only the triggerer's `clean_unused` 
callback in `jobs/triggerer_job_runner.py:clean_unused` was rewired. The 
scheduler still calls its own `_remove_unreferenced_triggers` from 
`jobs/scheduler_job_runner.py:2034`.
   
   ### Evidence
   
   `airflow-core/src/airflow/jobs/scheduler_job_runner.py:4206-4219`:
   
   ```python
   @provide_session
   def _remove_unreferenced_triggers(self, *, session: Session = NEW_SESSION) 
-> None:
       """Remove triggers that are no longer used by anything."""
       session.execute(
           delete(Trigger)
           .where(
               
~exists(select(AssetWatcherModel.trigger_id).where(AssetWatcherModel.trigger_id 
== Trigger.id)),
               ~exists(select(Callback.trigger_id).where(Callback.trigger_id == 
Trigger.id)),
               
~exists(select(TaskInstance.trigger_id).where(TaskInstance.trigger_id == 
Trigger.id)),
           )
           .execution_options(synchronize_session="fetch")
       )
   ```
   
   Existing tests are green at 
`airflow-core/tests/unit/jobs/test_scheduler_job.py:10051` and `:10087` 
(covering the unreferenced/referenced-path decision tree) but assume the 
unbounded form — they won't fail today because the table is small.
   
   ### Why I'm filing this
   
   A previous PR — **#68241** "Batch the scheduler's unreferenced-trigger 
cleanup deletes" — implemented the same fix and was closed as a stale draft on 
2026-07-02 (author inactive; `potiuk` moved it to draft because static / docs 
checks were failing). It would also re-roll the batching logic that already 
exists in `Trigger._delete_unused_batch`. **The triggerer side of this work 
landed (#70961); the scheduler side did not.**
   
   ### Proposed fix
   
   Reuse the helper `Trigger._delete_unused_batch` from the scheduler rather 
than re-rolling the LIMIT/with_row_locks/commit-between-batches loop. 
Concretely:
   
   1. Add a new config knob `[scheduler] 
unreferenced_triggers_cleanup_batch_size` (default `500`, `version_added: 
3.3.0` per the existing `[triggerer]` option pattern).
   2. Rewrite `_remove_unreferenced_triggers` to read that knob, validate it is 
`≥ 1`, and call `Trigger._delete_unused_batch(batch_size, session=session)`. 
The triggerer's `clean_unused` will keep the same cadence it has today (one 
batch per tick → drain over multiple ticks), so the net behaviour is the same 
modulo per-tick lock duration.
   3. Extend tests in `tests/unit/jobs/test_scheduler_job.py` with: a 
batching-commit-count test (e.g. 5 triggers, `batch_size=2` → 3 commits), a 
non-positive-batch-size rejection test mirroring 
`tests/unit/models/test_trigger.py:325`, and a sanity test that referenced 
triggers survive.
   
   ### Areas
   
   - area:scheduler
   - area:core
   
   ### Reproduction
   
   Hard to reproduce locally on a small fixture, but reproducible on any 
long-running install by tailing the scheduler logs for one cleanup tick after 
seeding N >> 500 unreferenced trigger rows; you will see the tick stretch to 
multiple seconds and any concurrent writer hitting `SELECT … FOR UPDATE` on 
`trigger` block.
   
   ### Airflow version
   
   main at `7fbcfeea27` (post-#75096).
   
   ### Follow-ups
   
   None expected — once the scheduler delegates to 
`Trigger._delete_unused_batch`, the cleanup pattern is fully owned by 
`Trigger`, and any future tuning (e.g. lock-skip policy, column-level WHERE 
tightening) edits one class.
   


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