Hyeonsang Cho b0cb3a3a8c
fix(scheduler): serialize the SQLite launch-budget claim (#5469)
* fix(scheduler): serialize the SQLite launch-budget claim

claim_queued_run counts the executing occurrences and then promotes one
row to launching. Postgres serializes that pair with a transaction
advisory lock; SQLite had no counterpart. pysqlite does not begin a
transaction for a SELECT, so the budget count ran in autocommit and the
deferred transaction reserved the writer only at the promoting UPDATE.
Claimers racing on distinct rows therefore read the same stale count,
each passed its own status == 'queued' CAS, and max_concurrent_runs was
exceeded.

A manual trigger overlapping the poller reaches this concurrently within
one process, and scheduler.multi_instance over a shared database file
reaches it across processes. Two claims of the same row were already
safe, which is why the existing coverage did not catch it.

Take the writer before the count with BEGIN IMMEDIATE, the idiom
ThreadMetaRepository already uses for its read-modify-write paths and
the same reservation _lock_task makes for a parent row. The claim
targets one row but the budget is global, so this has to be the
database-wide writer rather than a row lock.

* docs(changelog): reference #5469 in the SQLite launch-budget entry

* test(scheduler): pin the launch-budget test's connection reuse

The warm-up gather is what makes the claimers actually overlap, but it
silently depended on the SQLite engine keeping pooled connections. If
that engine ever moved to a non-pooling class, every claimer would open
its own connection, the per-connection PRAGMA setup would stagger them,
and this test would pass against an unserialized claim instead of
failing -- the cold-pool case it exists to avoid.

Assert that the warm-up left connections checked in. A non-pooling class
does not implement checkedin() at all, so a missing counter reads as
zero reuse and reports the same explanation rather than an
AttributeError. Verified against NullPool: the guard fails with
"NullPool left 0 connections pooled after the warm-up".

Only pool_size connections survive the gather (the overflow is
discarded), which is why the pre-fix failure is exactly five claimants
over a cap of one rather than eight.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-16 19:56:01 +08:00
..