fix(daemon): defer jobs refused the palace lock, don't fail them (#2014) - #2029
Merged
igorls merged 1 commit intoAug 2, 2026
Merged
Conversation
mvalentsev
force-pushed
the
fix/2014-daemon-lock-contention-retry
branch
3 times, most recently
from
July 16, 2026 08:00
76258ff to
6929d89
Compare
mvalentsev
marked this pull request as ready for review
July 16, 2026 08:10
…Palace#2014) A daemon job refused the palace write lock was marked terminal `failed` and never retried. `mine_palace_lock` guards the palace write itself, so a refusal means no drawer was filed, but the queue recorded it exactly like a crash mid-execution, whose outcome is unknown. The work was dropped: it only ran again if a hook happened to re-submit equivalent work, and a job kind no hook re-emits was lost outright. Refusals now defer. The job goes back to `queued` with `started_at` cleared, the claim's attempt increment undone, and the reason recorded. The update is scoped to the claim that was refused (started_at match), so a defer racing a recovery re-claim cannot re-queue work it does not own. `claim_next` clears the recorded reason so it never outlives the claim it describes. Every other failure stays terminal: a crashed job's outcome is unknown, and blindly re-running a non-idempotent kind would re-file verbatim content, which is what MAX_ATTEMPTS guards. The worker cools a refused job off in memory and moves on rather than sleeping in line. It is the only worker and the holder keeps the lock until its write finishes, which can be a long mine, so blocking would stall every unrelated job behind a lock that has nothing to do with them, and a job merely queued behind the refused one would never be claimed. `claim_next(exclude=...)` skips a cooling job, so the oldest-first ordering cannot hand the same refused job back forever while newer work waits. The filter runs in Python rather than an `id NOT IN (?, ?, ...)` list, which would bind one host parameter per cooling job against a cap that defaults to 999 before SQLite 3.32. The cooldown lives in the worker, not the schema, which has no migrations; a restart just retries at once, costing one refusal, never work. `tool_diary_write` swallowed `MineAlreadyRunning` in its bare `except Exception`, so the refusal reached the daemon with no `error_class` and `diary_write` would still have been dead-lettered. It now uses a typed handler ahead of the bare `Exception`, the way `tool_mine` and `tool_sync` already do. Deferral makes a refused job non-terminal, and `DaemonClient.wait` only returns on a terminal state, so callers that wait on purpose would have waited for a state a parked job cannot reach: a foreground `mine --daemon` for the one-hour default, and the `hooks_cli` pre-compaction mine and Stop-hook diary paths for their whole timeout on every fire, each then reporting a failure that never happened. `wait(stop_on_lock_deferral=True)` hands the parked job back instead. The CLI echoes the global `--palace` back into the command it suggests, so the suggestion does not silently list the default palace's queue instead of the one the job is parked in. A job that is genuinely running is still waited out. Co-Authored-By: mjvmsteixeira <185609735+mjvmsteixeira@users.noreply.github.com>
mvalentsev
force-pushed
the
fix/2014-daemon-lock-contention-retry
branch
from
July 22, 2026 06:51
6929d89 to
8d2de4a
Compare
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 does this PR do?
A daemon job refused the palace write lock was marked terminal
failedand never retried.mine_palace_lockguards the palace write itself, so a refusal means no drawer was filed, but the queue recorded it exactly like a crash mid-execution, whose outcome is unknown. The work was dropped: it only ran again if a hook happened to re-submit equivalent work, and a job kind no hook re-emits was lost outright.On develop, with a peer process holding the lock (the lock is re-entrant within one process, so the holder has to be external):
Refusals now defer instead of failing:
QueueStore.defer()puts the job back toqueued, clearsstarted_at, undoes the claim's attempt increment, and records why it was parked. A palace held across many claims can no longer walk work that never landed up toMAX_ATTEMPTS. Thestate = 'running'guard mirrorsfinish(only_if_running=True), so a job already cancelled by shutdown isn't resurrected, and the update is scoped to the claim that was refused (started_atmatch), so a defer racing a recovery re-claim cannot re-queue work it does not own.error_class == "LockHeldByOtherProcess", then cools the job off in memory and moves on to the next one. It does not sleep in line. This is the only worker, and the holder keeps the lock until its write finishes, which can be a long mine, so blocking here would stall every unrelated job behind a lock that has nothing to do with them.claim_next(exclude=...)skips a cooling job, so the oldest-first ordering cannot hand the same refused job back forever while newer work waits.not_beforecolumn would survive a restart, but the queue schema isCREATE TABLE IF NOT EXISTSwith no migration step, so persisting it means adding one, and the only thing a restart costs here is a single immediate refusal before the job cools off again. That did not seem worth a schema change in a bugfix; if persistence matters more than schema stability, anot_beforecolumn is the follow-up.claim_nextclears the recorded reason, so it never outlives the claim it describes.MAX_ATTEMPTSguards.MEMPALACE_DAEMON_LOCK_BACKOFF_SECONDS(default 60) tunes the cooldown. Anything not positive and finite falls back to the default:infparses fine and passes a bare positivity check, which would park the job until the daemon restarted.Why the retry is safe
diary_writeis the awkward case, so it is worth being precise._wal_logruns before the collection write, so a refusal happens after a WAL line was already appended. That line is an audit record:entryis in_WAL_REDACT_KEYS, the file has no readers anywhere in the tree, and nothing replays it. The palace write is what the lock guards and it did not happen, so re-running cannot duplicate verbatim content.Each retry does append another such record, so a palace held for a long time leaves one audit line per cooldown period for the job. That is log noise, not duplicated content, and it is the price of not dropping the write.
Callers that wait
Deferral makes a refused job non-terminal, and
DaemonClient.waitonly returns on a terminal state. Three callers wait on purpose, and all three would have waited for a state a parked job cannot reach:mine --daemon(foreground) would have blocked for the full one-hour default and then printeddaemon submission failed: timed out waiting for job ..., which is wrong twice over: the submission succeeded, and the job is still queued to run.hooks_cli(the pre-compactionmineatwait=True, timeout=60, and the Stop-hook diary checkpoint atwait=True, timeout=30) would have burned their whole timeout on every fire and then logged a failure that never happened.wait(stop_on_lock_deferral=True)hands the parked job back instead. The CLI reports the holder and a command that actually runs, and the hooks log the deferral rather than a failure:--palaceis global, so it has to be echoed back ahead of the subcommand; without it the suggestion silently lists the default palace's queue instead of the one the job is parked in.Because a cooling job no longer blocks the worker, a job submitted behind a deferred one is still claimed and refused on its own, so it gets its own marker and its caller short-circuits too. A job that is genuinely running (a long mine) is still waited out: the short-circuit only fires on
queuedplus the refusal marker. Background callers keep the old behaviour.The same bug class elsewhere
tool_diary_writehad to be fixed for the gate to reach it. It swallowedMineAlreadyRunningin its bareexcept Exception, so the refusal arrived with noerror_class, anddiary_write(one of the two kinds in the queue dump on the issue) would still have been dead-lettered. It now uses a typed handler ahead of the bareException, the waytool_mineandtool_syncalready do.Of the four job kinds,
mineandsyncmark the refusal inservice.run_mine/run_sync, anddiary_writeis fixed here. Two gaps are left deliberately, both pre-existing and both keeping exactly the behaviour they had before this change:mcp_tooldispatches the write tools, and four of them (tool_add_drawer,tool_update_drawer,tool_delete_drawer,tool_delete_by_source) reach a collection write but swallowMineAlreadyRunningin bare handlers, so their refusals would still be dead-lettered.tool_checkpointis a fifth, one step quieter: it files throughtool_add_drawerand buries the refusals in itserrorslist with no top-levelerror, so the job even reportssucceeded. Nothing submits that kind internally right now.mine_formatsswallows it per file and in its outer handler, so amine --mode extract --daemonjob reports success on a refusal rather than surfacing it at all. This one is reachable through the daemon today, and the refusal never leavesmine_formats, so the new gate cannot see it either way.All of these are worth their own fix; folding them in would mean touching most of the write surface in a bugfix PR.
How to test
python -m pytest tests/test_daemon.py -k "lease or defer or backoff or claim_clears or cooling or stall or behind or excluded or run_mine_lease" -v python -m pytest tests/test_hooks_cli.py -k lock_deferral -v python -m pytest tests/test_cli.py -k lock_deferral -v python -m pytest tests/test_mcp_server.py -k diary_write_lease_refusal -vEnd to end, with a peer process holding the lock:
Before the change the first
daemon jobsshowsfailed, and the job stays failed after the holder exits.Checklist
python -m pytest tests/ -v)ruff check .)Fixes #2014