Conversation
|
cc @alex-clickhouse, could you review this? It adds a Internal second-model review: adjudication log (click to expand)Pre-publication review by an independent model (engine: codex; 4 findings on the first pass, 1 on the recheck) plus my own cold read of the code on both.
Severity: ❌ blocker / Also noted, not blocking (💡): a Session id: cron:clickhouse-publish-slot-35:20260925-221300 |
6120d49 to
a2d66c0
Compare
|
Rebased on
|
|
The task tools read a row, edit the markdown, then write the row back from that read, and upsert_task replaced the row unconditionally. A completion landing in between was reverted: status went back, and file_path pointed into active/ for a file already moved to done/. TaskManager.mark_done also returned True without writing the row when the task file was already gone. tasks.revision (v045) advances on every write to a row. upsert_task and update_task_tags take expect_revision, and the new transition_task moves a task iff its status is in `expect` and its revision matches, in one guarded UPDATE that counts only when rowcount == 1: the shape of transition_review_loop, claim_wakeup and transition_workflow_run. update_task_status becomes a wrapper over it. The conditional upsert is a plain guarded UPDATE rather than the upsert statement, because an ON CONFLICT ... WHERE guard binds only the UPDATE arm and would re-create a row deleted since the read. task_done, task_reopen and mark_done now make one transition_task call carrying the status, the new file_path and the FTS text, instead of a status flip plus an upsert. task_read returns the token (in structured, and as a trailing HTML comment that task_write strips); task_update and task_done accept it as an optional expect_revision. With a token the row is written first, so a refused call writes nothing and touches no file. Without one, task_update, task_done and mark_done stay unconditional. A reopen is always conditional on the task still being done: it claims the row before moving the file and reports a refusal. Edits after a reopen are fenced on the revision the reopen wrote, and with a token an edit that has no task file to write is refused. The fence covers the row. Two callers that both pass it can still lose one another's markdown-body edits. Closes ClickHouse#453 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main already has v047-v049, and the runner skips every version at or below the database's current one, so an existing database never ran v045 and never got tasks.revision. v050 and v051 are taken by the open ClickHouse#404 and ClickHouse#422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sqlite3 module begins a transaction only at the first INSERT, UPDATE or DELETE, so a SELECT at the top of an _atomic() body ran outside it and another connection could commit between that read and the write based on it. _atomic() now issues BEGIN IMMEDIATE, which takes the write lock up front; a deferred BEGIN would instead fail the first write with SQLITE_BUSY_SNAPSHOT after such a commit. The account store issued BEGIN IMMEDIATE itself inside each of its seven _atomic() bodies; those lines are removed, since a second BEGIN now raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a2d66c0 to
484120e
Compare
|
Rebased on
Tests: three new |
|
Ok, let's make sure this gets merged after 404/422 then. |
|
run_migrations() reads MAX(version) once and skips every migration at or below it, so a migration merged with a number at or below the highest one already on main never runs on a database migrated to main; only fresh databases get it. Parallel PRs each pick the next free number against the main they forked from, so the second to merge can land below the first (#462 was first written as v045 while main was at v049). scripts/check_migration_numbers.py lists the base's migrations with git ls-tree -z (NUL-delimited, so git does not quote unusual names) and fails if a migration file present in the working tree but not on the base is numbered at or below the base's highest version. It uses the same vN_*.py rule as discover_migrations(), pinned by a test on the real directory. The new step runs last in backend-tests, on pull_request only, with !cancelled() so a test failure does not hide it. It fetches the base branch at run time instead of reading the merge commit's first parent, so re-running a PR's CI compares against today's main, not the main the PR was opened against. docs/architecture.md and the nerve-dev skill template still told contributors to name their migration v017 / v018. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge after #404 and #422, which add
v050andv051.task_updatereads a task row, rewrites the markdown, then writes the row back from that read, andupsert_taskreplaces it unconditionally. A completion landing in between is undone:statusreverts andfile_pathpoints back intoactive/for a file already indone/. Nothing on the row changes while that window is open, so no caller can fence against it (#453).TaskManager.mark_donealso returnedTruewithout writing the row when the file was already gone.The change
tasks.revision INTEGER NOT NULL DEFAULT 1; every write to a row advances it.transition_task(task_id, to_status, expect=..., expect_revision=..., **fields): one guardedUPDATE,Trueonly whenrowcount == 1, shaped liketransition_review_loop,claim_wakeupandtransition_workflow_run.update_task_statuswraps it.expect_revision=onupsert_taskandupdate_task_tags. The conditional upsert is a plain guardedUPDATE: anON CONFLICT ... WHEREguard would leave the INSERT arm free to re-create a deleted row.task_done,task_reopen,mark_donewrite status,file_pathand FTS text in onetransition_taskcall._atomic()begins withBEGIN IMMEDIATE, so a body's firstSELECTand the write based on it are one transaction against other connections too. The account store's own sevenBEGIN IMMEDIATElines go.task_readreturns the token (asstructuredand as a trailing HTML comment thattask_writestrips) and the REST task rows carryrevision.task_updateandtask_donetake an optionalexpect_revision; on a mismatch nothing is written and the conflict is reported. Without it writes stay last-write-wins, so the undo above is prevented only for writers that pass a token.task_updateforwards the token when it delegates totask_doneor to the reopen, and fences edits after a reopen on the revision the reopen wrote. A reopen now claims the row before moving the file, so its "is it still done?" check is atomic and a second concurrent reopen is refused instead of moving the file again.The fence covers the row, not the markdown body: two writers that both pass a current token can still lose each other's markdown edits, and since the row and the file are still two writes, a writer landing between them or a failure after the first can leave them disagreeing. Making them atomic is the open question on #453; this PR is its option A.
Validation
With a
task_doneinjected insidetask_update's window,mainreverts the completion; withexpect_revisionthe update is refused and the completion stands. Of the 46 new revision tests, 43 fail onmainand 3 pin unchanged behaviour, and each of 27 mutations fails its tests. Two of the three new_atomic()tests fail onmain. Full suite: 3950 passed vs 3901 onmain.Every writer of
tasksupsert_task, unconditional1;DO UPDATEaddsrevision = tasks.revision + 1upsert_task(expect_revision=)UPDATE, no INSERT armtransition_task(andupdate_task_status)UPDATEmove_taskupdate_task_tagsexpect_revisionupdate_task_escalation_renormalize_laneCloses #453
🤖 Generated with Claude Code