Repository navigation
fix(jobs): job locks are renewed leases that lapse when a pass dies; upgrade stops passes before the rebuild - #185
Merged
Merged
Conversation
joshpanka
force-pushed
the
fix/upgrade-job-locks
branch
from
October 13, 2026 15:36
a92e154 to
694c1a4
Compare
joshpanka
force-pushed
the
fix/upgrade-job-locks
branch
7 times, most recently
from
October 13, 2026 17:04
94ef492 to
80281f8
Compare
…upgrade stops passes before the rebuild A pass killed without unwinding (SIGKILL / OOM, or the core container being recreated under an exec'd pass, which is what `make upgrade` does) skipped its job_lock release and left the lock held for the full day-long TTL. Every later pass of that job then logged "already running; skipping" and the instance silently stopped polling until the TTL expired or someone ran clear_job_locks by hand. Seen on a real upgrade to v0.10.0. The lock is now a short liveness lease (JOB_LOCK_LEASE_SECONDS, default 300s) that SingleFlightCommand keeps alive with a LeaseRenewer daemon thread (common/lease_renewer.py) re-stamping it every third of the window for as long as the pass runs, the same pattern poll_feed already uses for its per-feed lease. A live pass of any length stays locked; a dead holder's renewals stop with it and the lock lapses within one window, however the pass was launched (up-jobs tickers, local-tick, cron) or killed. A renewal that raises (the DB-backed cache raises on a dropped connection rather than returning False, and Django never reconnects a dead connection object) is retried next tick on a fresh connection; the lease counts as lost only once a whole window has passed unrenewed. Liveness is not progress, so the renewer also gives up after JOB_LOCK_MAX_HOLD_SECONDS (default a day, well above the longest measured pass: ~46 min poll, ~4 h drain) so a hung-but- alive pass cannot strand the job the way an orphan used to. A lost lease is logged and the pass continues (single-flight is a convenience, not a correctness bound). clear_job_locks remains the immediate manual release. That convenience was load-bearing for one consumer: the digest flush gathers a window's runs with no claim fence, so two flushes of one window would both emit (a duplicate webhook POST) and both burn an attempt. process_due_digests now takes a per-window digest_flush_lock and skips a held window, putting the digest on the same footing as the drain's CAS claim and the poll's per-feed lock. `make upgrade` now signals in-container passes (stop_due_jobs) BEFORE the checkout, so it runs the old code against the old venv (the source is bind-mounted) and each pass releases its lock at once. It deliberately does NOT clear locks after the rebuild: a scheduler may already have started a fresh pass in the new container by then, and a blanket clear would free a live lock. Setting the removed JOB_LOCK_TIMEOUT_SECONDS now fails startup with a message naming the replacements, like the OLLAMA_URL rename did. BREAKING CHANGE: `JOB_LOCK_TIMEOUT_SECONDS` is replaced by `JOB_LOCK_LEASE_SECONDS` (default 300) and `JOB_LOCK_MAX_HOLD_SECONDS` (default 86400); leaving the old variable set now fails startup with a message naming the new ones. Upgrading from v0.10.0 or earlier: the running `make upgrade` is the OLD script (the shell keeps reading the file it already opened), which recreates the core container under any in-flight job pass, and a pass killed that way holds its lock for a day under the old code. If you run `make up-jobs`, run `make down-jobs` before upgrading and `make up-jobs` after. If `make up-jobs` afterwards reports a lock HELD with nothing running, clear it once: `make local-manage CMD="clear_job_locks --all"`.
joshpanka
force-pushed
the
fix/upgrade-job-locks
branch
from
October 13, 2026 17:09
80281f8 to
ef5c467
Compare
joshpanka
marked this pull request as ready for review
October 13, 2026 17:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A scheduled-job pass killed without unwinding left its job lock held for a full day, and the instance silently stopped running that job.
make upgradetriggers exactly this: it recreates thecorecontainer withdocker compose up --build, and a pass exec'd inside it (by theup-jobstickers,local-tick, or an operator's cron) is SIGKILLed, which skipsSingleFlightCommand's release. Every later pass then logsalready running; skippinguntil the TTL expires or someone runsclear_job_locks.Seen on a real upgrade to v0.10.0: the poll pass that started at 14:46 UTC was killed at 15:05 when the container was replaced,
clear_job_locks --all --dry-runshowedfeeds.poll_due_feedsHELD with no process in the container, and the host ticker skipped every pass from then on. Nothing was recorded after 14:41.How
The job lock becomes a short renewed lease, the same pattern
poll_feedalready uses for its per-feed lease, with a hard ceiling:JOB_LOCK_LEASE_SECONDS(default 300) replacesJOB_LOCK_TIMEOUT_SECONDS(86400). It is the liveness window, not a cap on pass length, so it is named as one;apps/core/AGENTS.mdnow states the rule (*_LEASE_SECONDSis renewed,*_TIMEOUT_SECONDSis a fixed TTL) and namesPOLL_LOCK_TIMEOUT_SECONDSas the misnamed one to rename with the feed-lease migration. Leaving the old variable set fails startup with a message naming the new ones, theOLLAMA_URLrename's precedent, and so does a lease under 3s or a max hold below the lease (the renewer ticks at a third of the lease floored at 1s, so a smaller lease flaps).SingleFlightCommand.executekeeps the lease alive with aLeaseRenewer(newcommon/lease_renewer.py): athreading.Threadsubclass with__enter__/__exit__, typed against a one-methodRenewableProtocol so it is unit-testable against a fake with no cache. It re-stamps the lease every third of the window for as long as the pass runs. A live pass of any length stays locked; a dead holder's renewals stop with it, so the lock lapses within one window however the pass was launched or killed.LockLease.renew's docstring and theAGENTS.mdbullet now say that the caller decides, and why the two consumers differ. A renewal that raises is retried next tick on a fresh connection: the DB-backed cache raises on a dropped connection rather than returning False, and Django never reconnects a dead connection object, so the thread drops its own thread-local connections and the next tick reopens them. The lease counts as lost only once a whole window has passed unrenewed; a lost lease is logged once and the pass continues, since single-flight is a convenience, not a correctness bound.JOB_LOCK_MAX_HOLD_SECONDS(default 86400) is the backstop the old TTL used to provide for a pass that is alive but hung: past it the renewer stops, logs, and the lease lapses. Measured on a real instance from the job logs: poll passes up to 46 min (mean 13 min, 1613 passes), drain passes up to 4 h 02 min (mean 6 s, 95k passes). Every connector and engine call carries a timeout, so a true hang is unlikely; the cap is cheap insurance.The one consumer that relied on single-flight for correctness gets its own fence. The digest flush gathers a window's PENDING runs with no claim; two flushes of one window would both emit (a duplicate webhook POST, two delivery rows) and both burn an attempt on a transient failure.
process_due_digestsnow takes a per-windowdigest_flush_lock(DIGEST_FLUSH_LOCK_TIMEOUT_SECONDS, 600) and skips a held window, reported in its summary line. Every otherSingleFlightCommandwas already overlap-safe by CAS claim, unique constraint, or per-feed lock;emit_telemetry_heartbeatis a plain command that self-throttles.Upgrade script.
scripts/upgrade.shrunsstop_due_jobsbefore thegit checkout, so it runs the old code against the old venv (the source is bind-mounted into the container, anduv runwould otherwise re-sync the venv under live passes) and each in-flight pass releases its lock at once. The comment says what the step does and does not buy: a scheduler can still launch a pass into the old container during the build and that one is killed, which the lease bounds to one window andmake down-jobsfirst avoids. No lock clearing after the rebuild, on purpose: a scheduler may already have started a fresh pass in the new container by then, and a blanket clear would free a live lock. A failed stop prints a line instead of being silent.Docs.
clear_job_locks,job_lock, the settings comments,apps/core/AGENTS.md, the README upgrade paragraph (with thedown-jobs/up-jobsadvice for ticker users) and theup-jobscomments describe the lease and the fence instead of the day-long TTL.Testing
common/tests_lease_renewer.py(6 tests, 1.3s) drivesLeaseReneweragainst a fake lease through itsintervaltest seam (20ms ticks, 150ms lease): the interval rule, its three exits (stopped by thewith, lease lost, max hold reached), a renewal that raises once and is retried, and one that keeps raising and counts as lost after one window. Run three times back to back for jitter; counts are asserted as ranges where a tick could move by one.common/tests_job_lease.py(5 tests) goes through aSingleFlightCommandon the locmem cache with a 3s lease: the lock outlives its lease while a 5s pass runs and a rival launch still skips; the renewer thread is gone after a normal return and after an exception; a holder taken throughjob_lockitself and abandoned without unwinding frees after one window (fails under a day-long lease, so it pins the real TTL); a lease cleared under a live pass is logged once and the pass finishes; with a 2s max hold a 6s pass loses its lock while still running.common/tests_job_lease_db.py(1 test,TransactionTestCaseon the realDatabaseCache): terminates the renewer thread's Postgres backend mid-pass withpg_terminate_backend; the next tick logsrenewal failedwith theOperationalError, reconnects, and the lock is still held under the same token at the end.watches/tests_digest_flush_lock.py(1 test): a window whose flush lock another pass holds is skipped byprocess_due_digests(runs still PENDING,1 skippedin the summary) and flushed on the next pass.exceptre-raises; the flush-lock test fails with the fence disabled; the dead-holder test fails under a day-long lease; the main renewal test fails with the renew interval neutered.manage.py test common watches feeds: 355 tests OK (thecommonsuite dropped from ~39s to ~24s with the seam).JOB_LOCK_TIMEOUT_SECONDS=1,JOB_LOCK_LEASE_SECONDS=2, andJOB_LOCK_MAX_HOLD_SECONDS=100each makemanage.py checkraise the namedImproperlyConfigured. The newstop_due_jobsline run live against a running stack:signaled 0 in-flight job process(es).shellcheck -s sh scripts/upgrade.sh, ruff, ty, and all pre-commit hooks clean.scripts/upgrade.shchecks out the latest release tag, so it is not meaningful against a dev checkout.Notes
make upgradeissh ./scripts/upgrade.sh, and the shell keeps reading the file it already opened aftergit checkoutswaps the inode (reproduced in sh, bash, dash and zsh). So v0.10.0's script runs the whole upgrade, with no stop step, and a pass in flight at the recreate holds its old-code day-long lock. Nothing in the script can fix that; the commit'sBREAKING CHANGEfooter carries the operator note into the release notes:make down-jobsbefore,make up-jobsafter, and a one-timeclear_job_locks --allifup-jobsreports a lock HELD with nothing running. Worst case for an operator who skips it is a day's lock, released by that command.LeaseReneweronpoll_lockand delete theheartbeatcallback threaded throughFeedPollOperation,RedditBatchMixin,Connector.poll,sleep_with_heartbeatand five connectors; expose alostEvent so the poll loop keeps its yield-on-loss check at the between-source seam; consider measuringmax_holdfrom the lasttouch()rather than total hold, with a per-command override; and move the keep-alive into a lock-level helper so stop-then-release ordering lives in one place.POLL_LOCK_TIMEOUT_SECONDSis the remaining renewed lease named as a timeout and would be renamed in the same pass.