fix(store): compare-and-set the wiki-link cascade's writes (BUG-2785) - #1213
Merged
Conversation
A document rename cascades through every document linking the old title
as a read-modify-write across two statements: SELECT each linker's
content, rewrite the string in Go, UPDATE the row. A content edit to a
linker committing between those two statements was silently overwritten
— the cascade wrote the body it built from the version it read, with no
error and no version row for the loss.
Same lost-update shape as BUG-2770's activity-metadata merge, one table
over, and fixed the same way: the UPDATE now carries `content = ?` with
the body the cascade read, plus a bounded retry that re-reads the row
and re-applies the rewrite.
DIALECT SCOPE, which decides what the tests can prove. Reachable on
POSTGRES only. SQLite's DSN sets `_txlock=immediate`, so UpdateDocument's
db.Begin() takes the write lock at BEGIN and holds it across the whole
read→write window; a concurrent edit cannot commit inside it and
serializes on busy_timeout instead. On Postgres under READ COMMITTED each
statement takes a fresh snapshot and the stale body wins. The CAS is
therefore a no-op on SQLite by construction — the predicate always
matches, because nobody else can have written.
Two consequences, both acted on rather than noted: the new tests SKIP
loudly on SQLite instead of passing for a reason unrelated to this fix,
and the mutation matrix was run under Postgres, where removing the CAS
leaves a SQLite suite entirely green.
The retry rewrites the WINNER's body rather than replaying the original
rewrite — replaying it would reintroduce exactly the text this bug loses.
A mutation that replays instead is in the matrix.
THE ZERO-ROW RESULT NEEDS A PROBE. The UPDATE now has two predicates that
can each refuse it, and RowsAffected cannot say which did: `deleted_at IS
NULL` (the linker was archived — a documented normal outcome, stop) or
`content = ?` (a concurrent edit landed — re-read and retry). Treating
them alike either retries forever against a deleted row or discards a
live linker's rewrite, so a probe distinguishes them, as BUG-2770 needed
for the same reason. The probe reads through tx, never the pool
(BUG-2409).
On retry exhaustion the RENAME fails rather than leaving one linker
holding a title that no longer exists. The alternative — log and continue
— was considered and rejected: it trades a loud retryable failure for a
silent inconsistency, and a rename is atomic in intent. Exhausting three
attempts needs three consecutive commits to the same linker inside one
cascade.
Adds afterLinkCascadeRead, the seam between the cascade's read and its
writes. No existing seam reaches that gap: afterDocumentPreLockRead fires
before the transaction, afterDocumentPreWrite before the renamed
document's own update.
That new seam also closes a gap a previous unit recorded as permanently
open: the `deleted_at IS NULL` guard carried a note calling itself
UNTESTED because reaching its window "would cost a fifth seam". This is
that fifth seam, so the note is removed and replaced by a record of the
closure, and the archived-linker test now drives exactly that window.
Mutation matrix, run under Postgres — 5 mutants, 5 detected, including
one that is literally the pre-fix code:
M1 CAS predicate removed (the unfixed behaviour) → lost-update test
M2 soft-delete probe arm removed → archived-linker test
M3 retry budget cut to one attempt → lost-update test
M4 retry replays the original rewrite → lost-update test
M5 seam removed (control: tests must notice they never raced)
→ lost-update test
Gates: gofmt clean, lint 0 issues, govulncheck clean, full suite green on
SQLite (28 pkgs) and on Postgres 17 (28 pkgs, internal/store 346s vs 79s
— the positive control that the PG legs ran rather than skipped).
Not fixed here, filed as BUG-2795: cascadeTitleRename, the ITEM-side
cascade, has the identical defect and no lock closes its window either.
Its fix does not transfer — it rewrites by POSITION from item_wiki_links
offsets, so a retry must re-derive positions from the winner's body and
re-run replaceWikiLinks, which is a redesign of the retry unit rather
than a predicate on an UPDATE. wiki_links.go's claim that it "matches the
document rename behavior" is corrected to say which half no longer
matches.
Same failure as the previous unit, same cause, and worth naming rather than quietly fixing: gofmt wants a blank line between list items once one item grows a second paragraph, which the note about the previously- untested deleted_at guard made true. I re-ran build and the full Postgres suite after those comment edits but not lint, because the change was 'only a comment' — the exact reasoning the previous unit's fix commit warned about in writing, one unit earlier. The PR body's claim that all gates were re-run after the prose edits was false and has been corrected there too. The rule as remembered does not work. The mechanical form does: gofmt and lint are the last action before a push, comment-only changes included.
…de contention honestly (BUG-2785)
Three findings from Codex round 2 on this PR. The first is a server hang.
1. ReplaceTitle could never terminate. replaceAll looped "find old in
result, splice new in" — re-searching the string it was building,
including the text it had just inserted. When the NEW title contains
the OLD link token it grows without bound.
Measured, not argued: ReplaceTitle("x [[A]] y", "A", "A]] [[A")
builds `[[A]] [[A]]`, which still contains `[[A]]`; a probe against
the old implementation ran 3s without terminating before being
killed. Document titles have no validation, so this is reachable from
user input — and the caller is inside the rename transaction holding
the workspace rename advisory lock (BUG-2778), so the hang would take
every other rename in that workspace down with it while exhausting
memory.
strings.Replace with n = -1 has the semantics that were wanted:
non-overlapping, left-to-right, over the input. Three-case regression
test, all three of which fail against the old implementation, plus a
control that catches a "fix" which terminates by doing nothing.
Pre-existing, and folded in rather than filed: three lines against a
server hang, and this PR's retry calls the helper again per attempt,
which makes it reachable more often than before.
2. Retry exhaustion surfaced as an opaque 500. The rename rolls back
cleanly and retrying can succeed, so "an internal error occurred"
tells the caller the opposite of the truth. Adds the exported
ErrLinkCascadeContention sentinel; the handler now answers 503
lock_contention with Retry-After, reusing the disposition BUG-2778
already established for 55P03/40P01.
3. That 503's message claimed the workspace was "busy with another
rename". 55P03 there is just as likely to be an ordinary content edit
holding the row, and the new arm is definitely one. It no longer
names a cause the server has not established.
Also closes the coverage gap round 2 named around this unit's own
decision: exhaustion now has a test asserting the rename ROLLS BACK
(target keeps its title) and that the concurrent editor's text survives
that rollback. cascadeRewriteAttempts becomes a var so the test can
force exhaustion at 1 rather than arranging three consecutive commits,
which would need a per-attempt hook in production code — the divergence
from its const sibling is noted where it lives.
Records two limitations in the code rather than leaving "the cascade is
safe now" to rot: the MIRROR direction is still open (a content writer
that read before this transaction can commit afterwards and reinstate
the old title — fixing it means giving ordinary content writes a CAS
too), and delete-then-restore of a linker mid-rename brings back the old
title. Both pre-existing, neither worsened here.
Mutation matrix now 7 mutants, 7 detected, run under Postgres:
M6 (%w -> %v, sentinel lost) and M7 (exhaustion swallowed) cover the new
mechanisms.
Gates re-run on the tree being pushed, tests included, after the final
comment edit rather than before it: gofmt clean, lint 0 issues, SQLite
28/28, Postgres 17 28/28 (internal/store 345s vs 79s). gofmt caught an
unformatted test file locally this time, which is the point.
…erage gap (BUG-2785) Codex round 4, on SQLite semantics and prose accuracy. One finding says a bug I FILED is wrong; that is the important one. 1. BUG-2795's premise was false, and this PR repeated it in a comment. I filed that item claiming the item-side cascade has "the identical defect" and that "no lock closes its window either", on the strength of a grep for pg_advisory_xact_lock in items.go that turned up only the parent-link locks. It missed acquireWorkspaceSeqLock (items.go:2136), taken UNCONDITIONALLY by every UpdateItem — content-only edits included — immediately after Begin and long before cascadeTitleRename, and held to COMMIT. So two item updates in a workspace fully serialize on Postgres and an ordinary content edit CANNOT commit inside that cascade's window. The scenario I filed is not reachable. Not fully invalid: sweeping every `SET content` writer in internal/store finds RemapAttachmentReferencesInWorkspace, which rewrites items.content in its own transaction without that lock. So a real but far narrower window survives — attachment remap versus cascade, not user-edit versus cascade. BUG-2795 corrected on its trail and dropped to low; the comment here now states the lock, the one surviving writer, and stops claiming parity with the document cascade. I searched for the locks I expected rather than for what serializes that path, then wrote a sentence broader than the search. Same failure this PR's review has produced repeatedly. 2. "A title nobody should be able to write" was false — the document API validates doc_type and status, never the title. Correcting it surfaced a real second defect: renaming to `A]] [[A` now terminates (round 2's fix) but writes `[[A]] [[A]]`, two links to nothing. Filed as BUG-2796. The termination test deliberately still asserts the COUNT rather than the output, so it does not freeze today's broken rendering as intended behaviour. 3. The hang's blast-radius claim named the workspace rename advisory lock without qualifying the dialect. That lock is a no-op on SQLite, where the equivalent damage is the database-wide write lock the transaction already holds under BEGIN IMMEDIATE. Different mechanism, same outcome for everyone else. Also closes the last coverage gap round 2 named. The CAS predicate runs on SQLite in production and every concurrency test skips there, so TestUpdateDocument_CascadeRewritesEveryLinkOnBothDialects does not skip: two linkers, multiple links per body, plus a document that merely contains the word and must be left alone. Verified it earns its place — a mutant comparing against the rewritten body instead of the body that was read compiles, and dies to this test ON SQLITE, where nothing else would have caught it. Documents the parallel-test constraint on the now-mutable cascadeRewriteAttempts, with its boundary: no current t.Parallel test in internal/store reaches this cascade, but that is a fact about today's corpus rather than an invariant. Gates on the pushed tree: gofmt clean, lint 0 issues, govulncheck clean, SQLite 28/28, Postgres 17 28/28 (internal/store 349s vs 79s).
…fy a claim in its second location (BUG-2785) Codex round 5, probing cross-connection visibility and transaction boundaries. Two findings, both mine, neither changing behaviour. 1. The "one surviving writer" framing was too comfortable, twice over. RemapAttachmentReferencesInWorkspace is not a rare non-interactive writer: bundle import reaches it on an ordinary user-triggered import, and the workspace is ALREADY VISIBLE to its owner while it runs — store.ImportWorkspace commits the workspace row (with owner_id) in its own transaction before opening the one that inserts items, and the bundle handler runs the remap as Phase 3 afterwards. Both verified in code. So that writer races ordinary item edits, not just the rename cascade, and it is missing a guard outright rather than being an exotic pairing. Filed as BUG-2797, which covers the remap itself; BUG-2795 is now a consequence of it and says so on its trail. The comment here points at the root cause instead of implying the cascade's pairing is the whole story. 2. The dialect-unqualified advisory-lock claim survived in a SECOND location. Round 4 caught it in links.go and I fixed it there; the same sentence sat in the termination test's comment, and I never enumerated the sites. That is CONVE-23's verify half failing exactly as it warns: I fixed the instance I was shown rather than the population. Both now qualified, and a sweep for remaining unqualified copies leaves only BUG-2778's own comment, which already carries its no-op-on-SQLite note. Worth recording that this is the second time on BUG-2795 that a scope sentence of mine ran ahead of the sweep supporting it — first "no lock closes its window either" (acquireWorkspaceSeqLock did), now "not user triggered" (import is). Both found by a review round rather than by me. Gates on the pushed tree: gofmt clean, lint 0 issues, SQLite 28/28, Postgres 17 28/28 (internal/store 348s vs 79s).
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.
Closes BUG-2785.
The defect
A document rename cascades through every document whose content links the old title.
Store.updateLinksInTxdoes that as a read-modify-write across two statements: SELECT each linker'scontent, rewrite the string in Go, UPDATE the row.A content edit to a linker that commits between those two statements was silently overwritten — the cascade's UPDATE was unconditional, so it wrote the body it built from the version it read. No error, and no version row for the loss, because the cascade does not create versions for the documents it rewrites.
Same lost-update shape as BUG-2770's activity-metadata merge, one table over.
Dialect scope — the fact that decides what any test here can prove
Reachable on Postgres only.
SQLite's DSN sets
_txlock=immediate(seestore.go), soUpdateDocument'sdb.Begin()issuesBEGIN IMMEDIATEand takes the write lock at BEGIN, holding it across the cascade's entire read→write window. A concurrent content edit cannot commit inside it — it serializes onbusy_timeout. On Postgres under READ COMMITTED each statement takes a fresh snapshot, the edit commits between the two statements, and the stale body wins.Three things follow, all acted on rather than just noted:
busy_timeout, and then assert about a serialized order that has nothing to do with the defect.)This is the dual-dialect foot-gun the repo warns about, arriving in the form where the SQLite side is the one that looks fine.
The fix
UPDATE documents SET content = ? WHERE id = ? AND deleted_at IS NULL AND content = ?, with the body the cascade read as the compare-and-set token, and a bounded 3-attempt retry.The retry rewrites the winner's body, not a replay of the original. Replaying the original rewrite would reintroduce exactly the text this bug loses. M4 in the matrix is that mutation.
The zero-row result needs a probe. The UPDATE now carries two predicates that can each refuse it, and
RowsAffectedcannot say which did:deleted_at IS NULLcontent = ?Treating them alike either retries forever against a deleted row or discards a live linker's rewrite. So a refusal is followed by a probe that tells them apart — the same shape BUG-2770 needed for the same reason. The probe reads through
tx, never the pool: a pool read from inside a transaction holding row locks is BUG-2409's deadlock.On exhaustion the rename fails. Considered and rejected: log-and-continue, which trades a loud retryable failure for a silent inconsistency. A rename is atomic in intent — either the title moves and its links follow, or neither — and a user can retry a failed rename, whereas nobody goes looking for a stale wiki-link. Exhausting three attempts needs three consecutive commits to the same linker inside one cascade, which is pathological rather than merely contended.
The seam, and a gap it closes that a previous unit had written off
afterLinkCascadeReadfires after every linker's content is read and before any is written back. No existing seam reaches that gap:afterDocumentPreLockReadfires before the transaction,afterDocumentPreWritebefore the renamed document's own update — a different window on a different row.The
deleted_at IS NULLguard carried a note from a previous unit calling itself UNTESTED and deliberately kept, because reaching its window needed a seam nothing could schedule "and which would cost a fifth one". This change adds that fifth seam for its own reasons, so the note is no longer true. It is removed and replaced by a record of the closure —TestUpdateDocument_CascadeTreatsSoftDeletedLinkerAsDonenow drives exactly that window, and M2 dies to it. Deleting another unit's deliberate "this is untestable" finding is a claim in its own right, so it is written down rather than quietly dropped.Tests
Two, both Postgres-only with loud skips. The lost-update test asserts three things, each catching a different wrong fix (CONVE-12 — assert what the WRONG behaviour would DO):
Mutation matrix — 5 mutants, 5 detected, run under Postgres
M1 being the unfixed behaviour is the receipt that these tests fail against broken code rather than passing for their own reasons. M5 is the control that matters most for a concurrency test: with the seam gone no race is scheduled at all, and a test that still passed would be measuring nothing.
Not fixed here — BUG-2795
cascadeTitleRename, the item-side cascade, has the identical defect, and no lock closes its window either (thepg_advisory_xact_lockcalls on that path cover parent-link cycles and parent-children ordering, not title renames).Filed rather than folded, for a reason rather than convenience: the fix does not transfer. The document cascade rewrites by SEARCHING for
[[oldTitle]], so re-applying it to a different body is just running the same search again. The item cascade rewrites by POSITION, using offsets read fromitem_wiki_links— after a concurrent edit those offsets are stale, so a retry must re-derive them from the winner's body, re-query the index inside the retry loop, and re-runreplaceWikiLinksagainst the body it actually wrote. That is a redesign of the retry unit, not a predicate added to an UPDATE.Found by this PR's CONVE-23 prose sweep:
wiki_links.goclaimed the item cascade "matches the document rename behavior — see documents.go::updateLinksInTx", which is what made the population question obvious once one of the two was fixed. That comment is corrected here to say which half no longer matches and to point at BUG-2795.One review finding declined, with the reasoning recorded
Codex round 6 (dimension: mixed-version rolling deployment) raised as P2 that during a rolling upgrade an old replica still runs the unconditional cascade, so the lost update remains possible until every writer is upgraded — and proposed an explicit drain / blue-green requirement before relying on the fix.
Declined as a blocker, and the "drain requirement" declined outright:
What is true and worth stating: this is an ordinary behaviour change with no schema component and no sequencing requirement. Deploy it normally; protection appears per-instance as instances update. Demanding a drain or blue-green for a fix whose interim state is "what production already does" would be ceremony, not safety.
Gates
gofmt clean;
make lint0 issues;make vulnno vulnerabilities affecting this codefull Go suite on SQLite: 28 packages, 0 failures
full suite against Postgres 17 on a private container (port 5463, not the shared 5445 — a sibling seat is live): 28 packages, 0 failures.
internal/storetook 346s vs 79s on SQLite — the positive control that the PG legs ran rather than skipped, which matters more than usual here because this fix is inert on SQLiteCorrection to this line as originally written. It claimed all gates were re-run after the prose edits, on the tree actually pushed. That was false: after the CONVE-23 comment edits I re-ran build and the Postgres suite but NOT lint, and CI caught a gofmt violation in one of those very comments (
documents.go:665— a list item that grew a second paragraph). Fixed in a follow-up commit; lint now 0 issues on the pushed tree, verified after the edit rather than before it.Worth stating plainly because it is the second time in one session, with the same cause: the previous unit's CI failure was the identical gofmt-in-a-doc-comment, and its fix commit message says "the gate has to run on the tree being pushed, not on an earlier one that resembles it". Knowing the rule did not prevent it. The reliable form is mechanical — run gofmt/lint as the last action before every push, including a comment-only one — not a rule to remember at the moment of judgement.