mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
427540706c
* fix(documents): bound the rename cascade at the title and at the projected total (BUG-2798, BUG-2796) A document rename rewrites [[oldTitle]] into every linking document. Neither factor of the output size was bounded: titles had no length validation, and the cascade holds every rewritten body in memory before writing any of them. One rename could project 10 GB from a 500 KB input -- 20,000x, measured -- and OOM while holding the workspace rename lock. Two walls, per Dave's day-63 ruling. 1. Title length, bounded at write time (models.MaxDocumentTitleRunes = 255). Runes, not bytes: "255 characters" is what a user and a UI counter mean. Existing over-limit titles stay valid until their next rename -- no retro-breakage of stored data. 2. The cascade's projected TOTAL, bounded at 16 MiB (store.MaxRenameCascadeProjectedBytes), accumulated across the linking set and refused before the first rewrite is built. The total is the right quantity and a per-document cap would not have been. Measured, with the title bound already in place: one linker holding the largest body a 2 MiB request can carry projects 108,632,370 bytes -- 51.8x -- and the aggregate is linear in the number of linkers (108.6 / 217.3 / 434.5 MB at k = 1/2/4, allocation tracking output at ~1.02x). A per-document cap of C still admits k * C, which is the same unbounded shape one level up. The 16 MiB figure has a receipt in the constant's doc comment: it sits above the absolute ceiling of any cascade this development instance could produce (its entire wiki-linking corpus is 10,077,476 bytes) and 6.5x below the single-document attack. The refusal is permanent-shaped and deliberately NOT in ErrLinkCascadeContention's family: 413 with the projection in the message and no Retry-After. Contention means "someone got there first, try again"; this means "this rename cannot be performed as asked". Answering it from the retryable family would tell a client to retry forever. BUG-2796 folds in at the same validation point, as ruled -- a title containing wiki-link syntax is emitted raw by links.ReplaceTitle, so renaming to `A]] [[A` produced two broken links and reported success. The rule is derived from the two mechanisms that consume a stored bracket (the grammar at markdown.ts:327 and the unescaper at markdown.ts:753) rather than from a character blacklist: the first version of this fix banned `]`, `\` and `|` because all three "look like wiki-link syntax", and the round-trip test refuted two thirds of that. `|` in particular is a title shape resolveWikiBody contains a dedicated branch to support, and `[` passes the grammar untouched. Doors enumerated rather than assumed (CONVE-24): store.CreateDocument and UpdateDocument have exactly two callers between them, both HTTP handlers. No CLI, import, or seed path writes a document title. Update previously validated doc_type and status and NOT title -- the one field that drives the cascade -- so the handler tests drive real requests through both doors (CONVE-19). BUG-2798, BUG-2796 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * fix(documents): count retained bytes, bound the retry path, escape the cascade's LIKE pattern (BUG-2798) Codex round 1 on #1218. Three findings, all real, all fixed here. 1. The guard bounded projected OUTPUT, which bounds nothing when the new title is SHORTER than the old one. Renaming a 255-character title to a one-character title makes each 2 MiB linker project ~40 KiB while the cascade still retains its 2 MiB read for the compare-and-set, so hundreds of linkers exhaust memory while the counter reports well under the cap. The counter now sums RETAINED bytes — read plus written, both alive at once — so the cap is a statement about resident memory rather than about output. MaxRenameCascadeProjectedBytes becomes MaxRenameCascadeRetainedBytes and moves 16 -> 32 MiB, because the legitimate ceiling it clears doubles under the new metric (that instance's whole wiki-linking corpus retains ~20,154,952 bytes); the single-document attack retains 110,729,522, so it is still refused by 3.3x. 2. The compare-and-set's retry path bypassed the guard entirely. On contention it re-reads the linker and calls ReplaceTitle on whatever the winner wrote — a NEW input, bounded by nothing the scan had checked — so a content edit landing inside the cascade's window could grow a linker from harmless to enormous and walk the rename back into the amplification it would have been refused for. Each document's compare-and-set now carries the cap less what the other linkers hold, and re-checks the grown body against it. 3. The cascade's `content LIKE ?` search term went in unescaped, so a document TITLE decided how the pattern was read. `\` is the default LIKE escape character on Postgres and NOT on SQLite, so `[[Alpha\Beta]]` was searched for as itself on one dialect and as `[[AlphaBeta]]` on the other: linkers not found, cascade rewrites nothing, rename reports success, every link left stale. Silent and dialect-dependent. Codex named the backslash; `%` and `_` are the rest of the class (CONVE-18) — wildcards on both dialects, so a title carrying them selects documents that do not link it. An explicit `ESCAPE '\'` clause plus escapeLikePattern makes both dialects agree, rather than leaving SQLite correct by accident. Finding 3 also constrains finding 3 of the ORIGINAL fix: models' validator allows a lone backslash in a title on the grounds that both renderers handle it, which was true of rendering and false of cascading. That comment now records the dependency — allowing it is only correct while the cascade's pattern stays escaped. Tests, four new, each mutation-verified against the code it guards: - CountsRetainedBytesNotJustOutput — the shrinking rename. Asserts as a PRECONDITION that the projected-output total stays under the cap, so the test cannot pass for the old reason. - RetryRecheckesTheBudgetAgainstTheGrownBody — drives the real race through the afterLinkCascadeRead seam. POSTGRES ONLY and skipped loudly elsewhere: SQLite's BEGIN IMMEDIATE closes the window structurally, so a green run there would be a property of the DSN. - FindsLinkersWhoseTitleContainsABackslash — Postgres only, same reasoning inverted: SQLite is the dialect that was accidentally right. - DoesNotSpendTheBudgetOnDocumentsThatDoNotLinkTheTitle — `%` and `_`. Its first version asserted the decoy's content was untouched and passed against the unescaped pattern, because over-matched rows rewrite to themselves. The observable harm is that they spend the caller's budget, so that is what it now asserts. Mutation matrix for this round: output-only counter -> only the shrinking test fails; retry check removed -> only the retry test fails (PG); LIKE unescaped -> the budget legs fail on SQLite and the backslash test fails on PG. Gates: `go test ./...` under Postgres 17 EXIT=0; SQLite packages EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798, BUG-2796 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * fix(documents): tighten the retry budget, stop charging no-op rewrites, order the typed check first (BUG-2798) Codex round 3 on #1218, an edge-case angle over the new arithmetic and control flow. Three findings fixed, one declined. 1. The retry budget credited back this document's own share, on the reasoning that the retry replaces it. It does not: the original read and rewritten bodies stay reachable through `updates` while the write loop runs, so the re-read and its rewrite are allocated ON TOP of them. The bound could be exceeded by up to one document's share while the arithmetic still reported it satisfied. The budget is now the genuine headroom, `cap - retained`. 2. A concurrent edit that REMOVES the link left a body with no occurrences, which cascadeRetainedBytes still charged twice — once for the read and once for a rewritten copy that does not exist, because strings.Replace returns its input unchanged when there is nothing to replace. That could refuse an otherwise valid rename for memory the cascade never allocates. 3. The handler classified this error by PROSE before testing it by identity. The UNIQUE-constraint arm matches a substring, and the refusal error embeds the caller's title verbatim, so renaming a document to a title containing the words "UNIQUE constraint" came back as a 409 name collision — advice to pick a different name, for a rename that was refused for size and would fail identically under any name. Typed sentinel now tested first. DECLINED: unchecked int64 arithmetic in the projection. The multiplicands are derived from the length of a string already resident in memory, so overflowing int64 needs a single document body of roughly nine exabytes; and the accumulator returns as soon as it passes the cap, so it cannot run away either. Saturating arithmetic here would be guarding a state the machine cannot reach. Tests, three new, each mutation-verified: - RetryBudgetExcludesThisDocumentsOwnStrings — deliberately separate from the existing retry test, because that one catches the check being ABSENT and this one catches it being too GENEROUS. The grown body is sized to fall BETWEEN the two budgets; a body far over the cap cannot tell them apart. - ConcurrentEditThatRemovesTheLinkDoesNotRefuseTheRename — its first version sized the link-free body against the CAP rather than against the retry's real headroom, so the refusal it caught was correct behaviour and the test was wrong, not the code. Re-sized against the headroom: fits when charged once, does not when charged twice. - IsNotMisreportedAsATitleCollision — at the handler, since the defect is entirely in its classification order. Mutation matrix for this round: credit the share back -> only the tight-budget test fails; charge the no-op body twice -> only the link-removed test fails; order the substring arm first -> only the misclassification test fails. Gates: `go test ./...` under Postgres 17 EXIT=0; touched packages re-run after the lint fix EXIT=0; gofmt clean; `make lint` 0 issues. CI green onb09aca12. BUG-2798, BUG-2796 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * test(documents): close four ways the cascade-bound tests could pass for the wrong reason (BUG-2798) Codex round 4, aimed at the TESTS rather than the code. No production behaviour changes here; four instruments that were weaker than they read. 1. Every retained-byte case exceeded the cap under `max(read, rewritten)` as well as under `read + rewritten`, so none of them could tell the two arithmetics apart — and taking the larger would hold twice the cap. Added CountsBothStringsNotTheLargerOne: an ordinary same-length rename over content totalling ~60% of the cap, which the sum refuses and the max admits. Its preconditions assert both halves of that gap. 2. Nothing pinned the 255 itself. Every length case derived its inputs from MaxDocumentTitleRunes, so changing the constant to 512 left them all green. That is fine for arithmetic and wrong for this number: it is a product decision Dave ruled, and a silent change to it silently changes how much amplification the cheap door lets through. Deliberately NOT done for MaxRenameCascadeRetainedBytes, which is mine and carries a measured receipt that is expected to be re-measured. 3. The oversize tests asserted only THAT a rename is refused, never that it is refused BEFORE the amplified string is built — which is the entire point of the guard. Moving links.ReplaceTitle above it would have kept them green. Added RefusesBeforeBuildingTheRewrittenBody, measuring cumulative allocation with a ~20x margin: refusing costs the one body it had to scan, building first costs ~108 MB. Verified by mutation — with the guard moved after the rewrite it reports 110,748,144 bytes against a 52,428,800 ceiling. This filing warned that measuring memory to prove the ABSENCE of amplification is flaky by construction. That still holds for the shape it described, a peak-RSS floor. This is the opposite: a generous ceiling on a deterministic counter, with the two outcomes twenty times apart. 4. The "reports the projection" assertions checked for the words `maximum` and `bytes`, which a message saying "maximum bytes exceeded" would satisfy while telling a caller nothing. They now require the cap's actual value and a real byte count. Mutation matrix: charge only the larger string -> only CountsBothStrings fails; move the rewrite above the guard -> only RefusesBeforeBuilding fails. Seventeen mutations across four rounds, each detected by the test that should catch it. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * fix(documents): compose the 413 from typed fields instead of splicing err.Error() (BUG-2798) Codex round 5, on the side effects of a REFUSED rename. Side effects were otherwise clean — rollback removes versions, link rewrites, attachment stamps and the title change, and no activity row, SSE event or webhook is emitted — but the response body was built by appending err.Error() to a public sentence. That published whatever any layer had wrapped around the error on its way up. Today that is "update links: store: ", which is a call path clients have no business seeing; tomorrow it is whatever the next wrapper adds, with no decision point in between. The response is now composed from typed fields on a new store.RenameCascadeTooLargeError (NewTitle, Retained, Max), reached with errors.As. Unwrap keeps errors.Is(err, ErrRenameCascadeTooLarge) true, so every existing sentinel check is unaffected. Both FIGURES stay in the message, deliberately: Dave's day-63 ruling asked the refusal to state what it would hold and what the cap is, so "split the rename" is actionable advice rather than a shrug. The reviewer read those numbers as a content-size oracle; that framing does not survive the trust boundary — a rename requires `editor`, documents are readable at `viewer`, so the caller can already read every document the figure summarises and learns nothing from it. What they had no business receiving was the internal call path, and that is what changed. This is round 3's lesson applied in the other direction. There, prose was being used to CLASSIFY an error and should have been identity. Here, prose was being used to REPORT one and should have been data. The test now asserts both halves: the real byte counts are present (not merely the word "maximum"), and the strings "update links:" and "store:" are ABSENT. Mutation-verified — splicing err.Error() back in fails on both leaked prefixes. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * docs(documents): correct eight claims the prose made that the code does not (BUG-2798) Codex round 6, aimed at the comments rather than the code. Eight findings, all mine, all real, no behaviour changed. This is the failure mode my own trail keeps naming — code right, prose broader than the sweep, always in the same direction — so they are corrected individually rather than smoothed over. Stale after the round-1 rename: - models cited store.MaxRenameCascadeProjectedBytes, which no longer exists. - the constant's own hostile figure read 110,729,522; it is 110,729,520. - the HTTP test said each body is ~135 KB; the formula produces 264,790 bytes. Claims wider than what is true: - The round-trip test's header stated a biconditional over the whole validator. False: a 300-rune title round-trips perfectly and is still refused, for the unrelated reason that it is an amplification factor. The property is about the SYNTAX rule, over titles inside the length bound, and now says so. - The mirrored grammar/unescaper comment claimed that a TypeScript change would make this test start disagreeing. It cannot — they are static copies, and nothing in the repository fails when the two drift. Replaced with what the duplication actually buys and what it does not. - The cap's receipt used `items` measurements to conclude the guard "cannot fire on honest use" for DOCUMENTS, having itself noted that instance's documents table is empty. The proxy is reasonable and it is an assumption, not a measurement of the guarded path; the inference is now named, with the narrower claim that survives without it. - The 413's comment cited the image_too_large precedent as a bound on OUTPUT while this guard bounds retained read-plus-write. What carries across is the shape — a small request refused for what handling it would cost — not the quantity. - TestRenameCascade_RefusesTheSingleDocumentAttack described the 2 MiB / 110,729,520-byte shape it does not build; it sizes from the cap (1,271,000 bytes retaining 67,108,800). The full-strength shape is exercised by the allocation test, and the comment now points there instead of describing a body that is not in the function. One figure was replaced by measurement rather than corrected by arithmetic: the allocation test claimed a "~20x margin". Both sides are now measured and stated separately — refusing allocates 2,114,624 bytes, ~24.8x under the 52,428,800 ceiling; building the rewrite first allocates 110,748,144, ~2.1x OVER it, taken from running the test against the mutation rather than computed. The smaller margin is the binding one, since it is the gap a regression must cross to be caught, and saying "~20x" hid that. Gates: `go test` on the three touched packages under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * fix(documents): stop charging the cascade budget for rows the rewriter cannot touch (BUG-2798) Codex round 7. Third instance of one class, and the one I stopped short of when I extended the previous two. The SELECT that finds candidate linkers is a LIKE; the thing that rewrites them is links.ReplaceTitle. They do not agree on what counts as a match, so the SELECT returns a SUPERSET — and every row in the difference was charged to the caller's retained-byte budget and then handed a no-op UPDATE. Instances one and two were `%` and `_`, wildcards on both dialects, closed by the ESCAPE clause. This is the case half, and it splits the OTHER way from the backslash bug: SQLite's LIKE is ASCII case-insensitive by default while Postgres's is case-sensitive, so renaming `Alpha` scans every body holding `[[alpha]]` on SQLite only. ReplaceTitle is case-sensitive on both and will never touch them, so enough case-variant content could push an otherwise valid rename to a 413 — on one dialect, for content that was never in scope. The fix is to skip a row with no case-sensitive occurrence outright, which closes both halves: no budget is spent, and no pointless UPDATE is issued for a body the cascade was never going to change. The authority on what is a linker is the rewriter's own count, not the pattern that proposed the candidate. The test runs on BOTH dialects deliberately, unlike its Postgres-only backslash sibling: on Postgres it asserts the behaviour was already correct, which is what makes it a regression test rather than a SQLite quirk shim. It also asserts the case variants are left byte-identical — `[[alpha]]` is a different link, not a missed one. Mutation-verified: restoring the charge fails it with 33,554,790 bytes against the 33,554,432 cap. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. An earlier attempt at that gate died on host disk exhaustion, not on this diff — 373 stale go-tmp directories from crashed runs, cleared, re-run green. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * fix(documents): report the whole operation's size when a retry is refused (BUG-2798) Codex round 8, on concurrency and the rest of the rename transaction. The advisory-lock lifetime, the CAS loop's termination, the ordering against attachment stamps and version writes, and the new skip's effect on the transaction's invariants all came back clean. One P2 stood. The retry path bounded correctly and REPORTED wrongly. It compared the re-read body against the headroom — right — and then named only that body in the error. Everything the scan counted is still held, so the operation's real size is the scan total plus the re-read, and a refusal could therefore say it would hold 16,777,200 bytes against a limit of 33,554,432: a refusal whose own figures do not justify it, which reads as a server bug rather than as advice you can act on. The compare-and-set is now handed the scan TOTAL instead of a pre-computed budget, so the same number both bounds and explains: refuse when scanTotal + grown exceeds the cap, and report scanTotal + grown. The test now asserts the refusal justifies itself — the figure reported must exceed the cap it cites — via the typed error added in round 5, which is what makes that property checkable at all rather than a string comparison. Mutation-verified: reporting the re-read alone fails it with exactly the 16,777,200-against-33,554,432 shape. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * fix(documents): refuse titles whose links the cascade cannot find; count retry buffers (BUG-2798) Codex round 9, asking what is MISSING rather than what is wrong. Three findings: two fixed, one already filed. ## Titles validated against the wrong layer The validator ACCEPTED `Alpha|Beta` and `Alpha\Beta`, and I defended that choice with a test. Both round-trip perfectly — the renderer reads each back as exactly the title it started from — and the first version of this fix banned them on vibes, so being shown that was a real correction. It was still the wrong call, for a reason that test could not see. A link to such a title can be STORED escaped, as `[[Alpha\|Beta]]`, and the rename cascade searches for the raw `[[Alpha|Beta]]` only. It does not find those links, so the rename succeeds and leaves them pointing at a title that no longer exists — silently, which is BUG-2796's defect wearing different syntax. So the property a title has to satisfy is stricter than the one I tested: not "the renderer reads it back", but "the renderer reads it back AND the cascade can find its links". Validating against the layer that DISPLAYS a title while the layer that MAINTAINS it disagrees is the same mistake as the unescaped LIKE, met from the other side — twice in one unit, which is the part worth noticing. `[` stays accepted: the cascade's search term matches it literally, so it passes the stricter property too. The two characters get their own test rather than a row in the round-trip table, because the table asserts a biconditional and these are refused for a reason that predicate deliberately does not model. That test asserts its own premise — each title must still round-trip — so if that ever stops being true it fails rather than passing for a new reason. ## Retry buffers were not counted rewriteLinkerCAS bounded each retry against the scan total plus THAT attempt. Earlier attempts' buffers become unreachable when expected/next are reassigned, but unreachable is not reclaimed, so a run of failures could hold several copies while the arithmetic counted one. Now accumulated across attempts, which is conservative — it counts garbage as if live — and errs toward refusing, which is the safe direction for a memory bound. The loop is capped at cascadeRewriteAttempts, so it cannot grow without end. ## Already filed Item renames remaining unbounded, and item titles lacking this validation, are real and out of this unit's scope: BUG-2804 and BUG-2805, filed after round 2 with the code verified rather than taken on report. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798, BUG-2796 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * fix(documents): validate a title only when the rename actually changes it (BUG-2798) Codex round 10, on back-compatibility. This one is a regression THIS fix introduced, not one it inherited, and it falsified a promise the fix makes about itself in three places. "Enforced at write time; existing titles stay valid until their next rename" is Dave's ruling, and it is repeated in the constant's doc comment and in two commit messages. Validating every SUPPLIED title broke it for the most ordinary shape of an edit there is: a client that PATCHes the whole object, title included, to change the content. Under that, a document with a legacy title became uneditable rather than merely un-renameable — the opposite of grandfathering. Grandfathering is not something you get by validating at write time. It is something you get by not validating a write that is not a rename. The check now fires only when the supplied title DIFFERS from the stored one, which is also exactly the test the store already applies before cascading, so the validation and the work it guards now agree on what counts as a rename. The regression test seeds its legacy document through the store, because the title it needs can no longer be created through the API — which is precisely the population the grandfathering clause exists for. Three legs: the echoed-title content edit succeeds, a title-less content PATCH succeeds, and renaming to another invalid title is still refused. The last is the control; without it, deleting the validation entirely would pass. Filed rather than folded: BUG-2806, existing documents whose links are stored in escaped form are still orphaned by a rename. Round 9 stopped NEW titles of that shape being created; it did not repair the ones already stored, and the asymmetry — the product refusing to create a shape it still mishandles — belongs in the record rather than in this PR, which is nine commits deep on a bound it has already outgrown. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * fix(documents): validate the rename under the lock, not against a pre-lock read (BUG-2798) Codex round 11. Round 10 moved title validation behind "only when the title actually changes", which is the right rule and was applied at the wrong place. The handler compares the supplied title against a document it read BEFORE the rename lock. UpdateDocument re-reads under the lock. Those can disagree: echo a legacy title back on a content edit while another request renames the document, and the handler sees "unchanged, skip validation" while the store sees a genuine rename — and writes the legacy title through with nothing having checked it. The rule is unchanged; the enforcement point moved to where the rename is actually decided. The store validates inside the transaction, on the same branch that triggers the cascade, and returns a typed InvalidDocumentTitleError carrying the reason. The handler keeps its pre-lock check, which is still worth having — it gives the common case a fast 400 without opening a transaction — and gains an arm that surfaces the store's refusal for the case its own check could not see. Grandfathering survives intact, because the store's check sits on the title-actually-changed branch, which is the same condition the handler uses. The test calls the store directly rather than reproducing the race: driving the interleaving would test the scheduler, while the property worth pinning is that the store refuses regardless of what a caller did. Its control leg is the grandfathering case — a content edit echoing the unchanged legacy title must still succeed — so validating everything here would fail it, which is round 10's regression restated as a guard. Mutation-verified: removing the store-side check leaves the handler tests green and fails this one with a nil error. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * perf(documents): stop counting every linker's occurrences twice (BUG-2798) Codex round 13, on what the guard costs the SUCCESS path rather than what it blocks on the failure path. The cascade counts occurrences because the size guard needs that number before it is willing to build anything. It then handed the same content to links.ReplaceTitle, whose strings.Replace with n < 0 counts it again. Every ordinary rename therefore paid a second full pass over every linking document for a number it already had. links.ReplaceTitleN takes the count the caller already computed. It is a separate function rather than an optional parameter because the obligation is real and silent when broken: passing a number that is too small does not error, it leaves later occurrences unrewritten, which on this path means links left pointing at a title that no longer exists. A name at the call site is cheaper than a comment nobody reads. NO measured speedup is claimed, and the doc comment says so. This removes one linear pass from a path that also allocates a full copy of the same content and issues a write per linker, so the saving is real but not obviously significant. It is here because doing the same work twice needs a reason and there was not one — not because a benchmark asked for it. The test asserts equivalence with ReplaceTitle across several shapes, including the new-title-embeds-old case, and its counterfactual leg asserts that an under-count visibly DIVERGES — if it did not, the caller's obligation would be imaginary and the API misleading. Round 13 also confirmed two things worth recording: no quadratic scan across linkers, and the ESCAPE clause does not materially change the query plan because the leading `%` already forced a content scan. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * docs(store): record the consequence the cascade cap necessarily has (BUG-2798) Codex round 14, on authorization and abuse. The refusal paths came back authorization-clean: reachable only after the workspace-access and `editor` checks, with viewers, guests, non-members and cross-workspace document IDs stopped first, and the reported byte figure exposing nothing an editor cannot already read. One consequence stands, and it is a property of having a cap at all rather than a defect in this one: once a workspace's documents linking a title exceed 32 MiB, that title can no longer be renamed, and any editor can put it in that state. Recorded in the constant's doc comment rather than fixed here, because the comparison that matters is with what it replaces. The same input previously took the server down for everyone; it now denies one operation to a role that can already delete every document in the workspace. Trading an unbounded OOM for a bounded, legible refusal is the point of the guard, not a gap in it. What IS missing is that the state has no exit but manual cleanup, with nothing telling an operator which documents to clean. That is a quota-and-recovery question rather than a cascade question, and it is filed as IDEA-2807 with three candidate shapes and an argument for the cheapest one — a state you can get out of is a different severity from one you cannot. The filing also says what is NOT established: no real workspace is known to approach the cap, and this fix's own receipt suggests none does, so it is a trap that exists rather than one anyone has fallen into. The adjacent concern the review raised — repeated near-cap renames contending for the rename lock and the connection pool — is noted there too, with the observation that the pre-existing behaviour was strictly worse, since each attempt was unbounded. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN * revert(links): remove ReplaceTitleN — it optimised nothing (BUG-2798) Codex round 15 caught a claim of mine that was simply false. Reverting the functional half ofc00606b0. I added ReplaceTitleN so the cascade could hand strings.Replace the occurrence count it had already computed, and wrote that this "removes one linear pass from a path that also allocates a full copy". It does not. strings.Replace calls Count UNCONDITIONALLY, before it looks at n: func Replace(s, old, new string, n int) string { if old == new || n == 0 { return s } // Compute number of replacements. if m := Count(s, old); m == 0 { return s } else if n < 0 || m < n { n = m } Read from this machine's GOROOT this turn, rather than recalled. Passing n constrains how many replacements are APPLIED; it does not skip the count. So the function bought nothing and cost something: a second way to do the same thing, carrying an obligation that fails SILENTLY when broken — an under-count leaves later occurrences unrewritten, which on this path means links pointing at a title that no longer exists. API surface with a silent failure mode and no payoff is worse than no API, so it goes rather than getting a corrected comment. The failure is the one my own trail keeps naming: I asserted a mechanism without reading it. What makes this instance worse than the earlier ones is that I wrote a careful hedge — "no measured speedup is claimed" — which reads as rigour while the sentence beside it stated the mechanism as fact. Declining to measure a claim is not the same as checking it, and the hedge made the unchecked claim look examined. The occurrence count stays where it is: the guard genuinely needs it before it will build anything, and computing it there is not redundant with anything the guard can avoid. Also declined this round, as already filed: renaming a legacy title with `|`, `\` or `]` leaves escaped links stale — that is BUG-2806, filed at round 10 with the mechanism verified in code. Gates: `go test ./...` under Postgres 17 EXIT=0; gofmt clean; `make lint` 0 issues. BUG-2798 Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN
1169 lines
49 KiB
Go
1169 lines
49 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/diff"
|
|
"github.com/PerpetualSoftware/pad/internal/links"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
func (s *Store) ListDocuments(workspaceID string, params models.DocumentListParams) ([]models.Document, error) {
|
|
query := `
|
|
SELECT id, workspace_id, title, slug, content, doc_type, status, tags,
|
|
pinned, sort_order, created_by, last_modified_by, source,
|
|
created_at, updated_at
|
|
FROM documents
|
|
WHERE workspace_id = ? AND deleted_at IS NULL
|
|
`
|
|
args := []interface{}{workspaceID}
|
|
|
|
if params.Type != "" {
|
|
query += " AND doc_type = ?"
|
|
args = append(args, params.Type)
|
|
}
|
|
if params.Status != "" {
|
|
query += " AND status = ?"
|
|
args = append(args, params.Status)
|
|
}
|
|
if params.Tag != "" {
|
|
tagExpr, tagArg := s.dialect.JSONArrayContains("tags", params.Tag)
|
|
query += " AND " + tagExpr
|
|
args = append(args, tagArg)
|
|
}
|
|
if params.Pinned != nil {
|
|
if *params.Pinned {
|
|
query += " AND pinned = TRUE"
|
|
} else {
|
|
query += " AND pinned = FALSE"
|
|
}
|
|
}
|
|
// Whitespace-only queries collapse to empty after FTS sanitization, and
|
|
// SQLite FTS5 errors on `MATCH ''`. Treat them as "no search filter" to
|
|
// match the !="" semantics callers expect. See BUG-818.
|
|
hasSearch := strings.TrimSpace(params.Query) != ""
|
|
if hasSearch {
|
|
// Use FTS for search
|
|
if s.dialect.Driver() == DriverSQLite {
|
|
ftsMatch := s.dialect.FTSMatch("documents_fts", "search_vector")
|
|
query = fmt.Sprintf(`
|
|
SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags,
|
|
d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source,
|
|
d.created_at, d.updated_at
|
|
FROM documents d
|
|
JOIN documents_fts fts ON d.rowid = fts.rowid
|
|
WHERE d.workspace_id = ? AND d.deleted_at IS NULL
|
|
AND %s
|
|
`, ftsMatch)
|
|
// Sanitize so FTS5 specials (hyphens, AND/OR/NOT, parens) are
|
|
// treated as literals rather than boolean operators — see BUG-818.
|
|
args = []interface{}{workspaceID, sanitizeFTSQuery(params.Query)}
|
|
} else {
|
|
// PostgreSQL: search_vector lives on the documents table (aliased as "d").
|
|
// PG FTSMatch consumes TWO args (raw + hyphen-sanitized) for the
|
|
// OR-combined plainto_tsquery — see dialect.go and BUG-842.
|
|
ftsMatch := s.dialect.FTSMatch("d", "search_vector")
|
|
query = fmt.Sprintf(`
|
|
SELECT d.id, d.workspace_id, d.title, d.slug, d.content, d.doc_type, d.status, d.tags,
|
|
d.pinned, d.sort_order, d.created_by, d.last_modified_by, d.source,
|
|
d.created_at, d.updated_at
|
|
FROM documents d
|
|
WHERE d.workspace_id = ? AND d.deleted_at IS NULL
|
|
AND %s
|
|
`, ftsMatch)
|
|
args = []interface{}{workspaceID, params.Query, sanitizePGFTSQuery(params.Query)}
|
|
}
|
|
|
|
if params.Type != "" {
|
|
query += " AND d.doc_type = ?"
|
|
args = append(args, params.Type)
|
|
}
|
|
if params.Status != "" {
|
|
query += " AND d.status = ?"
|
|
args = append(args, params.Status)
|
|
}
|
|
// Tag and Pinned filters were silently dropped by the FTS branch
|
|
// before this fix — see BUG-820 (documents analog of BUG-812).
|
|
if params.Tag != "" {
|
|
tagExpr, tagArg := s.dialect.JSONArrayContains("d.tags", params.Tag)
|
|
query += " AND " + tagExpr
|
|
args = append(args, tagArg)
|
|
}
|
|
if params.Pinned != nil {
|
|
if *params.Pinned {
|
|
query += " AND d.pinned = TRUE"
|
|
} else {
|
|
query += " AND d.pinned = FALSE"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort
|
|
sortCol := "updated_at"
|
|
if params.Sort != "" {
|
|
switch params.Sort {
|
|
case "title":
|
|
sortCol = "title"
|
|
case "created_at":
|
|
sortCol = "created_at"
|
|
case "updated_at":
|
|
sortCol = "updated_at"
|
|
case "sort_order":
|
|
sortCol = "sort_order"
|
|
}
|
|
}
|
|
order := "DESC"
|
|
if params.Order == "asc" {
|
|
order = "ASC"
|
|
}
|
|
|
|
if hasSearch {
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
// PostgreSQL ts_rank(): higher = more relevant → DESC.
|
|
// PG FTSRank consumes TWO args (raw + sanitized) — BUG-842.
|
|
ftsRank := s.dialect.FTSRank("d", "search_vector")
|
|
query += fmt.Sprintf(" ORDER BY %s DESC, d.%s %s", ftsRank, sortCol, order)
|
|
args = append(args, params.Query, sanitizePGFTSQuery(params.Query))
|
|
} else {
|
|
// SQLite FTS5: rank is a hidden column on the FTS JOIN (ascending = better)
|
|
query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order)
|
|
}
|
|
} else {
|
|
query += fmt.Sprintf(" ORDER BY pinned DESC, %s %s", sortCol, order)
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list documents: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanDocuments(rows)
|
|
}
|
|
|
|
func (s *Store) CreateDocument(workspaceID string, input models.DocumentCreate) (*models.Document, error) {
|
|
id := newID()
|
|
ts := now()
|
|
|
|
docType := input.DocType
|
|
if docType == "" {
|
|
docType = "notes"
|
|
}
|
|
status := input.Status
|
|
if status == "" {
|
|
status = "draft"
|
|
}
|
|
tags := input.Tags
|
|
if tags == "" {
|
|
tags = "[]"
|
|
}
|
|
createdBy := input.CreatedBy
|
|
if createdBy == "" {
|
|
createdBy = "user"
|
|
}
|
|
source := input.Source
|
|
if source == "" {
|
|
source = "web"
|
|
}
|
|
|
|
baseSlug := slugify(input.Title)
|
|
if baseSlug == "" {
|
|
baseSlug = "untitled"
|
|
}
|
|
slug, err := s.uniqueSlug("documents", "workspace_id", workspaceID, baseSlug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Transactional so the attachment-reference stamp commits atomically with
|
|
// the content that carries the reference (BUG-2415's protocol, extended to
|
|
// documents by BUG-2614). Without the transaction the stamp and the insert
|
|
// could not serialize against a concurrent orphan-GC claim, which is the
|
|
// entire point of stamping.
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin create document: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// BEFORE the insert, per stampAttachmentRefsTx's ORDERING note: on
|
|
// Postgres the stamp row-locks the attachment rows for the rest of the
|
|
// transaction, so a concurrent claim blocks and then re-evaluates against
|
|
// the fresh stamp. Stamping after the write would leave a gap.
|
|
if err := stampAttachmentRefsTx(tx, s, workspaceID, input.Content); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = tx.Exec(s.q(`
|
|
INSERT INTO documents (id, workspace_id, title, slug, content, doc_type, status, tags,
|
|
pinned, sort_order, created_by, last_modified_by, source, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
|
|
`), id, workspaceID, input.Title, slug, input.Content, docType, status, tags,
|
|
s.dialect.BoolToInt(input.Pinned), createdBy, createdBy, source, ts, ts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert document: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit create document: %w", err)
|
|
}
|
|
|
|
return s.GetDocument(id)
|
|
}
|
|
|
|
func (s *Store) GetDocument(id string) (*models.Document, error) {
|
|
return s.getDocumentQ(s.db, id)
|
|
}
|
|
|
|
// getDocumentTx reads a document through an OPEN TRANSACTION, so the caller
|
|
// sees the row as its own transaction sees it and needs no second pool
|
|
// connection while the first is held (BUG-2778). Same SELECT and hydration
|
|
// as GetDocument.
|
|
//
|
|
// Deliberately NOT `FOR UPDATE`. An earlier version locked the row here to
|
|
// stop a concurrent soft-delete committing before the write at the end of the
|
|
// rename — but the rows-affected guard on that write already makes the
|
|
// outcome atomic (the whole transaction, cascade included, rolls back), so
|
|
// the lock changed only WHICH writer wins, not whether the result is
|
|
// consistent. A mutation removing it survived the suite, which is the honest
|
|
// signal that it was a second mechanism for a window one mechanism already
|
|
// covers; the remaining guard has its own justification and its own test.
|
|
func (s *Store) getDocumentTx(tx *sql.Tx, id string) (*models.Document, error) {
|
|
return s.getDocumentQ(tx, id)
|
|
}
|
|
|
|
// getDocumentQ is the one document-row read behind GetDocument and
|
|
// getDocumentTx, differing only in executor.
|
|
func (s *Store) getDocumentQ(q rowQueryer, id string) (*models.Document, error) {
|
|
var d models.Document
|
|
var createdAt, updatedAt string
|
|
var deletedAt *string
|
|
var pinned bool
|
|
|
|
err := q.QueryRow(s.q(`
|
|
SELECT id, workspace_id, title, slug, content, doc_type, status, tags,
|
|
pinned, sort_order, created_by, last_modified_by, source,
|
|
created_at, updated_at, deleted_at
|
|
FROM documents
|
|
WHERE id = ? AND deleted_at IS NULL
|
|
`), id).Scan(
|
|
&d.ID, &d.WorkspaceID, &d.Title, &d.Slug, &d.Content, &d.DocType, &d.Status, &d.Tags,
|
|
&pinned, &d.SortOrder, &d.CreatedBy, &d.LastModifiedBy, &d.Source,
|
|
&createdAt, &updatedAt, &deletedAt,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d.Pinned = pinned
|
|
d.CreatedAt = parseTime(createdAt)
|
|
d.UpdatedAt = parseTime(updatedAt)
|
|
d.DeletedAt = parseTimePtr(deletedAt)
|
|
return &d, nil
|
|
}
|
|
|
|
func (s *Store) UpdateDocument(id string, input models.DocumentUpdate) (*models.Document, error) {
|
|
existing, err := s.GetDocument(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Test seam (BUG-2778): the read above is stale by exactly this window,
|
|
// which is why the rename decision and the cascade's old title come from
|
|
// the re-read below. Nil in production.
|
|
if s.afterDocumentPreLockRead != nil {
|
|
s.afterDocumentPreLockRead(id)
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// BUG-2778: serialize TITLE RENAMES per workspace on Postgres, before
|
|
// this transaction takes any row lock.
|
|
//
|
|
// A rename locks rows in two stages: updateLinksInTx writes every OTHER
|
|
// document whose content links the old title, and the UPDATE at the end
|
|
// of this function writes THIS document. Two concurrent renames of
|
|
// documents that link to each other therefore take the same two row locks
|
|
// in opposite orders — tx1 locks B then wants A, tx2 locks A then wants B
|
|
// — and Postgres aborts one with SQLSTATE 40P01. Measured, not argued:
|
|
// before this lock, a probe that renamed two mutually-linking documents
|
|
// concurrently deadlocked on 12 of 12 rounds.
|
|
//
|
|
// WHY NOT `ORDER BY id` ON THE CASCADE, which is what BUG-2778 proposed
|
|
// when it was filed from reading rather than from a repro: the cycle does
|
|
// not come from the ORDER of the cascade's own rows. Each transaction's
|
|
// cascade set here is a single row, and the cycle is cascade-then-self.
|
|
// Ordering the cascade leaves it exactly as reachable; only a rule that
|
|
// covers BOTH stages removes it.
|
|
//
|
|
// A workspace-scoped advisory lock rather than a sorted lock batch,
|
|
// because the two stages touch different tables and different row sets
|
|
// (versions, the slug-uniqueness scan) and a sorted batch would have to
|
|
// predict all of them. It is the same instrument BUG-2074 uses for
|
|
// parent-edge writes, on its own namespaced key so renames contend only
|
|
// with renames. No-op on SQLite, whose single writer cannot produce the
|
|
// cycle at all (the probe found zero deadlocks there).
|
|
//
|
|
// It fires whenever a title is SUPPLIED, not only when the supplied title
|
|
// differs from what we read before the lock — deciding that from the
|
|
// pre-lock value is precisely the staleness this block exists to remove,
|
|
// and a same-title PATCH is a cheap uncontended lock acquisition.
|
|
if input.Title != nil {
|
|
if err := s.acquireWorkspaceDocumentRenameLock(tx, existing.WorkspaceID); err != nil {
|
|
return nil, err
|
|
}
|
|
// Re-read UNDER the lock and decide from that row, not from the
|
|
// pre-transaction read above (codex round 1). `existing` was loaded
|
|
// before this transaction and before this lock, so a rename that
|
|
// committed in between is invisible to it — and every decision below
|
|
// is made from it: whether to cascade at all, and which OLD TITLE the
|
|
// cascade rewrites. Two concurrent renames of the SAME document could
|
|
// therefore leave backlinks pointing at a title nothing carries any
|
|
// more, or skip the cascade entirely because the stale row's title
|
|
// happens to equal the requested one. This is the same defect family
|
|
// as BUG-2776 one layer down: a decision made from a snapshot taken
|
|
// before the lock that protects it.
|
|
//
|
|
// The lock is taken whenever a title is SUPPLIED rather than only
|
|
// when it differs from the stale read — deciding that from the stale
|
|
// value is exactly what this block exists to stop.
|
|
fresh, ferr := s.getDocumentTx(tx, id)
|
|
if ferr != nil {
|
|
return nil, fmt.Errorf("re-read document under rename lock: %w", ferr)
|
|
}
|
|
if fresh == nil {
|
|
// Deleted between the pre-tx read and the lock; the caller's 404
|
|
// path handles a nil document.
|
|
return nil, nil
|
|
}
|
|
existing = fresh
|
|
}
|
|
|
|
// Stamp the incoming content's attachment references first (BUG-2614,
|
|
// same protocol and ordering as items and comments). Only when content is
|
|
// actually being written — a metadata-only PATCH neither adds nor keeps a
|
|
// reference, and stamping on one would refresh rows this write has no
|
|
// opinion about.
|
|
if input.Content != nil {
|
|
if err := stampAttachmentRefsTx(tx, s, existing.WorkspaceID, *input.Content); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
ts := now()
|
|
|
|
// Create version if content is changing (throttled to avoid bloat from auto-save)
|
|
if input.Content != nil && *input.Content != existing.Content {
|
|
createdBy := input.LastModifiedBy
|
|
if createdBy == "" {
|
|
createdBy = "user"
|
|
}
|
|
source := input.Source
|
|
if source == "" {
|
|
source = "web"
|
|
}
|
|
|
|
// Title changes always get a version; content-only changes are throttled
|
|
forceVersion := input.Title != nil && *input.Title != existing.Title
|
|
shouldVersion := forceVersion
|
|
if !shouldVersion {
|
|
// Through the transaction, not the pool (BUG-2778): this runs
|
|
// with the transaction open and, for a rename, with the rename
|
|
// lock held.
|
|
shouldVersion, err = s.shouldCreateVersionQ(tx, id, createdBy, source)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check version throttle: %w", err)
|
|
}
|
|
}
|
|
|
|
if shouldVersion {
|
|
vid := newID()
|
|
|
|
// Store a reverse diff (patch from new → old) instead of full content.
|
|
// Falls back to full content if the diff isn't meaningfully smaller.
|
|
versionContent := existing.Content
|
|
isDiff := false
|
|
patch := diff.CreateReversePatch(existing.Content, *input.Content)
|
|
if diff.IsDiffSmaller(patch, existing.Content) {
|
|
versionContent = patch
|
|
isDiff = true
|
|
}
|
|
|
|
_, err = tx.Exec(s.q(`
|
|
INSERT INTO versions (id, document_id, content, change_summary, created_by, source, is_diff, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), vid, id, versionContent, input.ChangeSummary, createdBy, source, s.dialect.BoolToInt(isDiff), ts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create version: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update [[link]] references if title is changing.
|
|
//
|
|
// KNOWN GAP, filed as BUG-2629, deliberately not fixed here: this cascade
|
|
// rewrites OTHER documents' bodies without stamping the attachment
|
|
// references in the text it writes, and wiki_links.go::cascadeTitleRename
|
|
// does the same on the items side. Uniform across both surfaces, so fixing
|
|
// only this one would leave the larger hole open. It is also the weakest
|
|
// member of that family — the cascade rewrites link text in content whose
|
|
// references were already stamped when written and are still visible to
|
|
// the scan, so a NEW reference requires a title that literally contains a
|
|
// `pad-attachment:` token.
|
|
if input.Title != nil && *input.Title != existing.Title {
|
|
// Validated HERE, not only at the handler, because here is where the
|
|
// rename is decided — under the lock, against the title this
|
|
// transaction re-read (codex round 11).
|
|
//
|
|
// The handler's check compares against a document it read BEFORE the
|
|
// lock. That is fine for giving a caller a fast, friendly 400, but it
|
|
// is a time-of-check that a concurrent rename can invalidate: echo a
|
|
// legacy title back while another request renames the document, and
|
|
// the handler sees "unchanged, skip validation" while this branch sees
|
|
// a genuine rename and would write the legacy title through. Same
|
|
// grandfathering rule, applied where the decision actually happens.
|
|
if msg := models.ValidateDocumentTitle(*input.Title); msg != "" {
|
|
return nil, &InvalidDocumentTitleError{Reason: msg}
|
|
}
|
|
err = s.updateLinksInTx(tx, existing.WorkspaceID, existing.Title, *input.Title)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update links: %w", err)
|
|
}
|
|
}
|
|
|
|
// Build update query
|
|
sets := []string{"updated_at = ?"}
|
|
args := []interface{}{ts}
|
|
|
|
if input.Title != nil {
|
|
sets = append(sets, "title = ?")
|
|
args = append(args, *input.Title)
|
|
// Update slug too, ensuring uniqueness within workspace
|
|
baseSlug := slugify(*input.Title)
|
|
if baseSlug == "" {
|
|
baseSlug = "untitled"
|
|
}
|
|
newSlug, err := s.uniqueSlugExcluding(tx, "documents", "workspace_id", existing.WorkspaceID, baseSlug, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unique slug: %w", err)
|
|
}
|
|
sets = append(sets, "slug = ?")
|
|
args = append(args, newSlug)
|
|
}
|
|
if input.Content != nil {
|
|
sets = append(sets, "content = ?")
|
|
args = append(args, *input.Content)
|
|
}
|
|
if input.DocType != nil {
|
|
sets = append(sets, "doc_type = ?")
|
|
args = append(args, *input.DocType)
|
|
}
|
|
if input.Status != nil {
|
|
sets = append(sets, "status = ?")
|
|
args = append(args, *input.Status)
|
|
}
|
|
if input.Tags != nil {
|
|
sets = append(sets, "tags = ?")
|
|
args = append(args, *input.Tags)
|
|
}
|
|
if input.Pinned != nil {
|
|
sets = append(sets, "pinned = ?")
|
|
args = append(args, s.dialect.BoolToInt(*input.Pinned))
|
|
}
|
|
if input.SortOrder != nil {
|
|
sets = append(sets, "sort_order = ?")
|
|
args = append(args, *input.SortOrder)
|
|
}
|
|
if input.LastModifiedBy != "" {
|
|
sets = append(sets, "last_modified_by = ?")
|
|
args = append(args, input.LastModifiedBy)
|
|
}
|
|
if input.Source != "" {
|
|
sets = append(sets, "source = ?")
|
|
args = append(args, input.Source)
|
|
}
|
|
|
|
if s.afterDocumentPreWrite != nil {
|
|
s.afterDocumentPreWrite(id)
|
|
}
|
|
|
|
args = append(args, id)
|
|
// deleted_at IS NULL, and the row count is CHECKED (BUG-2778): without
|
|
// it, a document soft-deleted since this transaction began is written
|
|
// anyway — and on the rename path the backlink cascade above has already
|
|
// rewritten every linker, so the caller is told not-found (GetDocument
|
|
// filters archived rows) while those rewrites stay behind. Returning
|
|
// early here leaves the deferred Rollback to undo the cascade with it, so
|
|
// the rename and its cascade land together or not at all.
|
|
//
|
|
// This is the ONLY thing standing between a concurrent soft-delete and a
|
|
// write to the archived row — on every path, rename or not. A
|
|
// content-only PATCH takes no rename lock and holds no row lock until
|
|
// this statement, so the delete can land at any point before it.
|
|
query := fmt.Sprintf("UPDATE documents SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(sets, ", "))
|
|
res, err := tx.Exec(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update document: %w", err)
|
|
}
|
|
affected, err := res.RowsAffected()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update document: rows affected: %w", err)
|
|
}
|
|
if affected == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.GetDocument(id)
|
|
}
|
|
|
|
// acquireWorkspaceDocumentRenameLock serializes document TITLE RENAMES within
|
|
// a workspace on Postgres (BUG-2778). Its key is namespaced so it contends
|
|
// only with other renames — reusing acquireWorkspaceSeqLock's bare workspace
|
|
// key would have made every rename wait behind every item-number and seq
|
|
// write in the workspace, and vice versa, for no benefit: the cycle this
|
|
// closes is rename-against-rename (codex round 2). Same shape and no-op
|
|
// dialect gate as acquireWorkspaceParentLinkLock.
|
|
func (s *Store) acquireWorkspaceDocumentRenameLock(tx *sql.Tx, workspaceID string) error {
|
|
if s.dialect.Driver() != DriverPostgres {
|
|
return nil
|
|
}
|
|
// BOUNDED WAIT (codex round 5). The transaction has already taken a pool
|
|
// connection by the time it waits here, so an unbounded wait converts
|
|
// lock contention into pool exhaustion: a burst of renames in ONE
|
|
// workspace can pin connections and stall unrelated work, and a client
|
|
// disconnecting does not cancel the wait (the HTTP context is not
|
|
// threaded into this call).
|
|
//
|
|
// WHAT THE 5s DOES AND DOES NOT COVER, because a bound with no receipt
|
|
// invites more trust than it earns: it bounds each LOCK WAIT inside this
|
|
// transaction once the connection is already held. It does NOT bound the
|
|
// transaction's total duration, and it does NOT bound the wait for a pool
|
|
// connection BEFORE Begin() — a saturated pool still queues callers ahead
|
|
// of this code. The number is a safety cap chosen to be far above any
|
|
// plausible uncontended acquisition, NOT a tuned value: no rename-duration
|
|
// or cascade-size percentile has been measured, and this comment should
|
|
// not be read as if one had. Measure before treating 5s as meaningful.
|
|
//
|
|
// SET LOCAL, so it dies with the transaction rather than leaking back to
|
|
// the pool. It also bounds the row-lock waits later in this transaction,
|
|
// which is intended by the same argument.
|
|
if _, err := tx.Exec("SET LOCAL lock_timeout = '5s'"); err != nil {
|
|
return fmt.Errorf("set rename lock timeout: %w", err)
|
|
}
|
|
// An operator seeing contention here sees it as wait_event_type='Lock',
|
|
// wait_event='advisory' in pg_stat_activity, with this query text — the
|
|
// 'pad:document-rename:' literal is what identifies the class. The
|
|
// workspace id is a bound parameter and will not appear in the text;
|
|
// pg_blocking_pids() on the waiter finds the holder.
|
|
if _, err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext('pad:document-rename:' || $1))", workspaceID); err != nil {
|
|
return fmt.Errorf("acquire workspace document-rename lock: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// escapeLikePattern escapes the three characters that carry meaning inside a
|
|
// LIKE pattern, for use with an explicit `ESCAPE '\'` clause.
|
|
//
|
|
// Without this the cascade's own search term is interpreted as a pattern, and
|
|
// a document TITLE decides how (BUG-2798, codex round 1 P2 — plus the rest of
|
|
// the class it was an instance of):
|
|
//
|
|
// - `_` and `%` are wildcards in BOTH dialects, so a title containing them
|
|
// selects documents that do not link it. Those extra rows rewrite to
|
|
// themselves, so the damage is not corruption — it is that the guard below
|
|
// is computed from this result set, so an over-broad pattern spends a
|
|
// caller's budget on rows that were never going to change.
|
|
// - `\` is where the two dialects DISAGREE, which is the dangerous half.
|
|
// Postgres LIKE treats backslash as the default escape character; SQLite
|
|
// LIKE has no default escape character at all. So `[[Alpha\Beta]]` is
|
|
// searched for as the literal it is on SQLite and as `[[AlphaBeta]]` on
|
|
// Postgres — the linking documents are simply not found, the cascade
|
|
// rewrites nothing, and the rename succeeds leaving every link stale. A
|
|
// silent, dialect-dependent data defect.
|
|
//
|
|
// The explicit ESCAPE clause makes both dialects agree, rather than leaving
|
|
// SQLite correct by accident and Postgres wrong by default.
|
|
func escapeLikePattern(s string) string {
|
|
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func (s *Store) updateLinksInTx(tx *sql.Tx, workspaceID, oldTitle, newTitle string) error {
|
|
// Find all documents in the workspace that contain [[oldTitle]]
|
|
searchTerm := "[[" + oldTitle + "]]"
|
|
rows, err := tx.Query(s.q(`
|
|
SELECT id, content FROM documents
|
|
WHERE workspace_id = ? AND deleted_at IS NULL AND content LIKE ? ESCAPE '\'
|
|
`), workspaceID, "%"+escapeLikePattern(searchTerm)+"%")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
type docUpdate struct {
|
|
id string
|
|
// read is the body this cascade rewrote FROM, kept verbatim: it is
|
|
// the compare-and-set token below, so it must be the exact string
|
|
// the column handed us and never a normalized form of it.
|
|
read string
|
|
rewritten string
|
|
// retained is what this row contributed to the running total, kept so
|
|
// the compare-and-set below can be given ITS share of the budget when
|
|
// a concurrent edit forces it to re-read and re-rewrite.
|
|
retained int64
|
|
}
|
|
var updates []docUpdate
|
|
var retained int64
|
|
for rows.Next() {
|
|
var du docUpdate
|
|
if err := rows.Scan(&du.id, &du.read); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Project what this linker will make the cascade HOLD, before building
|
|
// it, and refuse on the running TOTAL across the linking set
|
|
// (BUG-2798).
|
|
//
|
|
// The total is the right thing to bound, and a per-document cap would
|
|
// not be. Measured: with the title bound in place, one linker holding
|
|
// the largest body a 2 MiB request can carry projects 108,632,370
|
|
// bytes of output — 51.8x — and the loop below holds EVERY rewritten
|
|
// body in `updates` before it writes any of them, so k linkers hold k
|
|
// times that (measured linear at k = 1/2/4). A per-document cap of C
|
|
// still admits k * C, which is the same unbounded shape one level up.
|
|
//
|
|
// RETAINED bytes, not output bytes. An earlier version of this guard
|
|
// summed only the projected output, which bounds nothing when the new
|
|
// title is SHORTER than the old one: renaming a 255-character title to
|
|
// a one-character title makes each 2 MiB linker project about 40 KiB
|
|
// while the cascade still retains its 2 MiB read for the
|
|
// compare-and-set, so hundreds of linkers exhaust memory while the
|
|
// counter reports well under the cap (codex round 1 P1). Both strings
|
|
// are alive at once, so both are counted.
|
|
//
|
|
// Refusing here rather than after the loop is what makes the bound
|
|
// real: at the moment of refusal the process holds the linkers already
|
|
// counted (under the cap by construction) plus this one row's body,
|
|
// and none of the amplified output.
|
|
// The SELECT is a LIKE, and LIKE is not the rewriter. On SQLite it is
|
|
// ASCII case-INSENSITIVE by default (Postgres's is not), so renaming
|
|
// `Alpha` scans every body containing `[[alpha]]` — which
|
|
// links.ReplaceTitle, being case-sensitive, will not touch. Charging
|
|
// those bodies to the budget lets case-variant content that can never
|
|
// be rewritten push a legitimate rename over the cap, on one dialect
|
|
// only (codex round 7).
|
|
//
|
|
// Skipping them is the fix for both halves: no budget is spent, and no
|
|
// no-op UPDATE is issued for a row whose content the cascade was never
|
|
// going to change. Same class as the `%`/`_` over-matching the ESCAPE
|
|
// clause closed — the pattern selects a superset of the linkers, and
|
|
// the authority on what is actually a linker is the rewriter's own
|
|
// case-sensitive count.
|
|
occurrences := int64(strings.Count(du.read, searchTerm))
|
|
if occurrences == 0 {
|
|
continue
|
|
}
|
|
|
|
du.retained = cascadeRetainedBytes(du.read, occurrences, oldTitle, newTitle)
|
|
retained += du.retained
|
|
if retained > MaxRenameCascadeRetainedBytes {
|
|
return newRenameCascadeTooLargeError(newTitle, retained)
|
|
}
|
|
|
|
du.rewritten = links.ReplaceTitle(du.read, oldTitle, newTitle)
|
|
updates = append(updates, du)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Test seam (BUG-2785): the last moment at which a concurrent content
|
|
// edit to a linker can still commit ahead of the writes below, which is
|
|
// the whole window this function's compare-and-set exists to survive.
|
|
// Nil in production. No existing seam reaches here — afterDocumentPreLockRead
|
|
// fires before the transaction and afterDocumentPreWrite fires before the
|
|
// renamed document's OWN update, neither of which is this gap.
|
|
if s.afterLinkCascadeRead != nil {
|
|
s.afterLinkCascadeRead(workspaceID)
|
|
}
|
|
|
|
for _, du := range updates {
|
|
// The compare-and-set is handed the scan's TOTAL, not a pre-computed
|
|
// budget, so it can both bound and REPORT correctly: a retry must fit
|
|
// in what remains under the cap, and a refusal must name the whole
|
|
// operation's size rather than the re-read alone (codex rounds 3, 8).
|
|
//
|
|
// Everything the scan counted is still held — `updates` references the
|
|
// original read and rewritten bodies for every linker, this one
|
|
// included — so a re-read is allocated ON TOP of them. An earlier
|
|
// version credited this document's share back, on the reasoning that
|
|
// the retry replaces it; it does not, and the bound could be exceeded
|
|
// by up to one document's share while the arithmetic reported it
|
|
// satisfied.
|
|
if err := s.rewriteLinkerCAS(tx, du.id, du.read, du.rewritten, oldTitle, newTitle, searchTerm, retained); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cascadeRetainedBytes is what one linking document makes the cascade hold:
|
|
// the body it read (kept verbatim as the compare-and-set token) plus the body
|
|
// it will write.
|
|
//
|
|
// Exact rather than an estimate. strings.Replace substitutes every
|
|
// non-overlapping occurrence, so the rewritten length is
|
|
// len(read) + occurrences * (len(new) - len(old)) to the byte, and this
|
|
// function is the only place that arithmetic lives — the scan and the retry
|
|
// path must not be allowed to drift apart on it.
|
|
func cascadeRetainedBytes(read string, occurrences int64, oldTitle, newTitle string) int64 {
|
|
if occurrences == 0 {
|
|
// No second string exists to charge for: strings.Replace returns its
|
|
// input unchanged when there is nothing to replace, so ReplaceTitle
|
|
// allocates nothing and `rewritten` aliases `read`.
|
|
//
|
|
// This is not a micro-optimisation, it is a correctness case on the
|
|
// retry path (codex round 3 P2): a concurrent edit that REMOVES the
|
|
// link leaves a body with no occurrences, and charging it twice could
|
|
// refuse an otherwise valid rename for memory the cascade never
|
|
// allocates.
|
|
return int64(len(read))
|
|
}
|
|
rewritten := int64(len(read)) + occurrences*int64(len(newTitle)-len(oldTitle))
|
|
return int64(len(read)) + rewritten
|
|
}
|
|
|
|
// RenameCascadeTooLargeError carries the refusal's NUMBERS as typed fields, so
|
|
// a caller-facing layer can compose its own sentence instead of splicing this
|
|
// error's text into a response.
|
|
//
|
|
// The distinction matters (codex round 5): the HTTP handler used to append
|
|
// err.Error() verbatim, which meant every wrapper any caller added on the way
|
|
// up — "update links: " today, anything at all tomorrow — was published to the
|
|
// client as part of a public message. The two figures ARE meant to reach the
|
|
// caller (Dave's day-63 ruling: the refusal states what it would hold and what
|
|
// the cap is, so "split the rename" is actionable advice rather than a shrug);
|
|
// the internal call path is not.
|
|
//
|
|
// This is the round-3 lesson applied in the other direction: there, prose was
|
|
// being used to CLASSIFY an error and should have been identity; here, prose
|
|
// was being used to REPORT one and should have been data.
|
|
type RenameCascadeTooLargeError struct {
|
|
// NewTitle is the caller's own requested title. Echoed back deliberately:
|
|
// it is theirs, and it is what they need to see to understand which
|
|
// rename was refused.
|
|
NewTitle string
|
|
// Retained is the lower bound on bytes the cascade would have held. A
|
|
// lower bound, not a total: the scan stops at the first row that crosses
|
|
// the cap, so the true figure is larger.
|
|
Retained int64
|
|
// Max is the cap in force.
|
|
Max int64
|
|
}
|
|
|
|
func (e *RenameCascadeTooLargeError) Error() string {
|
|
return fmt.Sprintf("%s: renaming to %q would hold at least %d bytes of linked-document content, maximum %d",
|
|
ErrRenameCascadeTooLarge.Error(), e.NewTitle, e.Retained, e.Max)
|
|
}
|
|
|
|
// Unwrap makes errors.Is(err, ErrRenameCascadeTooLarge) hold, so every existing
|
|
// sentinel check keeps working.
|
|
func (e *RenameCascadeTooLargeError) Unwrap() error { return ErrRenameCascadeTooLarge }
|
|
|
|
func newRenameCascadeTooLargeError(newTitle string, retained int64) error {
|
|
return &RenameCascadeTooLargeError{
|
|
NewTitle: newTitle,
|
|
Retained: retained,
|
|
Max: MaxRenameCascadeRetainedBytes,
|
|
}
|
|
}
|
|
|
|
// ErrLinkCascadeContention reports that a rename's link cascade lost its
|
|
// compare-and-set on the same linking document too many times in a row.
|
|
//
|
|
// It is a CONTENTION signal, not a fault: the rename rolled back cleanly and
|
|
// retrying it can succeed. Exported so the HTTP layer can answer 503 with a
|
|
// Retry-After instead of the 500 that "an internal error occurred" implies —
|
|
// a caller told the request will never succeed will not retry, which is the
|
|
// opposite of the truth here (codex round 2 on BUG-2785).
|
|
var ErrLinkCascadeContention = errors.New("store: link cascade lost the compare-and-set")
|
|
|
|
// ErrInvalidDocumentTitle reports a rename to a title the wiki-link machinery
|
|
// cannot carry. See InvalidDocumentTitleError for why the store enforces this
|
|
// rather than trusting its callers to have done so.
|
|
var ErrInvalidDocumentTitle = errors.New("store: invalid document title")
|
|
|
|
// InvalidDocumentTitleError carries the human-readable reason a title was
|
|
// refused, so the HTTP layer can return it without re-deriving the rule or
|
|
// splicing an internal error's text into a response.
|
|
type InvalidDocumentTitleError struct{ Reason string }
|
|
|
|
func (e *InvalidDocumentTitleError) Error() string {
|
|
return ErrInvalidDocumentTitle.Error() + ": " + e.Reason
|
|
}
|
|
|
|
func (e *InvalidDocumentTitleError) Unwrap() error { return ErrInvalidDocumentTitle }
|
|
|
|
// ErrRenameCascadeTooLarge reports that a rename was refused because the
|
|
// linked-document content it would hold exceeds MaxRenameCascadeRetainedBytes.
|
|
//
|
|
// Deliberately NOT in ErrLinkCascadeContention's family, and the distinction
|
|
// is the caller-visible one: contention means "someone else got there first,
|
|
// try again"; this means "this rename cannot be performed as asked, and
|
|
// retrying it unchanged will fail identically until the workspace's content
|
|
// changes." One wants 503 + Retry-After, the other a permanent 4xx carrying
|
|
// the projection so the caller can see what it asked for. Blurring the two
|
|
// vocabularies would tell a client to retry forever (BUG-2798, lead ruling
|
|
// day-63).
|
|
var ErrRenameCascadeTooLarge = errors.New("store: rename cascade exceeds the retained-content bound")
|
|
|
|
// MaxRenameCascadeRetainedBytes bounds the TOTAL linked-document content a
|
|
// single rename may hold in memory: for every linking document, the body read
|
|
// plus the body written.
|
|
//
|
|
// RETAINED rather than merely projected-output, because output alone is not
|
|
// the resource. A rename to a SHORTER title projects less output than its
|
|
// input while still holding every read body for the compare-and-set — so an
|
|
// output-only counter reports ~40 KiB per 2 MiB linker and bounds nothing in
|
|
// that direction (codex round 1). Counting both strings makes the cap a
|
|
// statement about resident memory, which is what actually runs out.
|
|
//
|
|
// 32 MiB, and both bounds of the gap are measured rather than picked:
|
|
//
|
|
// - Legitimate ceiling. In this development instance's database — a mature
|
|
// workspace set, 206 MB on disk — the ENTIRE corpus of wiki-linking
|
|
// content is 2,949 items totalling 10,077,476 bytes (largest single body
|
|
// 86,147 bytes). A cascade over all of it would retain read + rewritten,
|
|
// so ~20,154,952 bytes. That is the absolute ceiling on any conceivable
|
|
// single cascade over that corpus: it assumes every wiki-linking document
|
|
// links the one title being renamed, which no real workspace does. 32 MiB
|
|
// is ~1.6x that impossible worst case.
|
|
//
|
|
// The INFERENCE is worth naming rather than hiding, because the guard it
|
|
// justifies is on documents and the measurement is not (codex round 6):
|
|
// that instance's `documents` table is EMPTY, so there is no direct
|
|
// figure to take. `items` is used as the proxy on the grounds that the
|
|
// two hold the same kind of prose and cascade the same way — a reasonable
|
|
// assumption, not a measurement of the guarded path. What can be said
|
|
// without the proxy is narrower and still useful: a workspace whose
|
|
// documents linking one title total more than ~16 MB of content will meet
|
|
// this cap. If real document corpora ever get that large, this number is
|
|
// the thing to re-measure.
|
|
//
|
|
// - Hostile floor. A single linking document holding the largest body a
|
|
// 2 MiB request can carry retains 110,729,520 bytes once the title bound
|
|
// is in place — 3.3x this cap — so the attack is refused at k = 1 and
|
|
// every k above it, rather than at some threshold count of documents.
|
|
//
|
|
// The gap is deliberate and wide: a cap has to be far enough above real use
|
|
// that nobody meets it by accident, and far enough below the hazard that
|
|
// meeting it costs nothing.
|
|
//
|
|
// The consequence a cap necessarily has, stated because it is user-visible
|
|
// and was chosen rather than overlooked: once a workspace's documents linking
|
|
// one title exceed this, that title can no longer be renamed, and any EDITOR
|
|
// can put it in that state by creating enough linking content. That is a
|
|
// denial of one operation by a trusted role — an editor can already delete
|
|
// every document in the workspace — and it replaces the previous behaviour,
|
|
// where the same input took the server down for everybody. Trading an
|
|
// unbounded OOM for a bounded, legible refusal is the whole point of the
|
|
// guard, not a gap in it.
|
|
//
|
|
// Bounding the WORKSPACE's linking content, so the state cannot be reached at
|
|
// all, is a quota question rather than a cascade question and is filed
|
|
// separately.
|
|
//
|
|
// What it does NOT cover, stated so the next reader does not over-read it:
|
|
// this bounds ONE rename's linked-document content, not concurrent renames (N
|
|
// of them may each hold up to this), and not the base cost of a workspace
|
|
// whose linking documents are legitimately large — a cascade under the cap
|
|
// still allocates whatever it holds.
|
|
const MaxRenameCascadeRetainedBytes = 32 << 20
|
|
|
|
// cascadeRewriteAttempts bounds rewriteLinkerCAS's retry loop.
|
|
//
|
|
// Three, matching debounceMergeAttempts' reasoning rather than copying its
|
|
// number by coincidence: one attempt to lose, one to win against the writer
|
|
// that beat it, and one of headroom for a second writer arriving mid-retry.
|
|
// Exhausting it needs three consecutive commits to the SAME linker inside one
|
|
// cascade, which is pathological contention rather than ordinary editing.
|
|
//
|
|
// It is a LIVENESS bound, not a correctness one: correctness comes from the
|
|
// compare-and-set predicate, which cannot write a stale body however many
|
|
// attempts it is given.
|
|
// A var rather than a const — deliberately diverging from its sibling
|
|
// debounceMergeAttempts, which is a const — so a test can lower it to 1 and
|
|
// drive exhaustion deterministically. Forcing exhaustion at 3 would need a
|
|
// concurrent commit between each attempt, which needs a per-attempt hook in
|
|
// production code; lowering the bound tests the same disposition with less
|
|
// test-only surface.
|
|
//
|
|
// Never written outside tests, and a test that writes it must not run under
|
|
// t.Parallel() alongside anything that renames a DOCUMENT — same constraint the
|
|
// Store's test seams carry, for the same reason: it is process-global state
|
|
// with no synchronization. internal/store does contain parallel tests
|
|
// (items_empty_assignment, item_mutation_signal, watches), and none of them
|
|
// reaches this cascade today — but that is a fact about the current test
|
|
// corpus, not an invariant, which is why the constraint is written here rather
|
|
// than inferred from the absence of a collision.
|
|
var cascadeRewriteAttempts = 3
|
|
|
|
// rewriteLinkerCAS applies a title rewrite to one linking document, retrying
|
|
// on a lost compare-and-set.
|
|
//
|
|
// WHY A CAS AT ALL (BUG-2785). The cascade is a read-modify-write across two
|
|
// statements: the SELECT above reads a linker's content, Go rewrites the
|
|
// string, and this UPDATE writes the result. A content edit committing between
|
|
// those two statements is silently erased by an unconditional UPDATE — the
|
|
// same lost-update shape as BUG-2770's activity-metadata merge, one table over.
|
|
//
|
|
// DIALECT SCOPE, because it decides whether any test here means anything.
|
|
// The lost update is reachable on POSTGRES only. The SQLite DSN sets
|
|
// `_txlock=immediate` (see store.go), so UpdateDocument's db.Begin() takes the
|
|
// write lock at BEGIN and holds it across this whole window; a concurrent
|
|
// content edit cannot commit inside it and serializes on busy_timeout instead.
|
|
// On Postgres under READ COMMITTED each statement takes a fresh snapshot, so
|
|
// the edit commits between the two statements 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 — and load-bearing on Postgres.
|
|
//
|
|
// WHY THE ZERO-ROW RESULT NEEDS A PROBE. The UPDATE carries two predicates
|
|
// that can each refuse it, and RowsAffected cannot say which did:
|
|
//
|
|
// - deleted_at IS NULL — the linker was soft-deleted after the SELECT. That
|
|
// is the NORMAL outcome of a documented race (the guard predates this
|
|
// change): the linker is gone, so there is no link left to keep consistent,
|
|
// and the right response is to move on.
|
|
//
|
|
// That guard used to carry a note saying it was UNTESTED and kept anyway,
|
|
// because reaching its window needed a seam no test 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 and has been removed rather
|
|
// than left to mislead: TestUpdateDocument_CascadeTreatsSoftDeletedLinkerAsDone
|
|
// now drives exactly that window, and a mutation removing the probe arm
|
|
// below dies to it. Recorded here because deleting a previous unit's
|
|
// deliberate "this is untestable" finding is a claim in its own right,
|
|
// and the next reader deserves to know it was closed rather than lost.
|
|
//
|
|
// - content = ? — a concurrent edit landed. The right response is the
|
|
// opposite: re-read, re-apply the rewrite to the NEW body, and try again.
|
|
//
|
|
// Treating the two alike would either retry forever against a deleted row or
|
|
// discard a live linker's rewrite. So a refusal is followed by a probe that
|
|
// distinguishes them, 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 that holds row locks is BUG-2409's deadlock.
|
|
//
|
|
// WHAT THIS DOES NOT FIX, named because "the cascade is now safe" is the claim
|
|
// that would rot silently (codex round 2 enumerated both):
|
|
//
|
|
// - THE MIRROR DIRECTION. This stops the cascade losing a content writer's
|
|
// edit. It does not stop a content writer losing the CASCADE's rewrite: a
|
|
// writer that read the body BEFORE this transaction, then blocked on the
|
|
// row lock, commits its own unconditional UPDATE afterwards and reinstates
|
|
// the old title. Fixing that means giving ordinary content writes a
|
|
// compare-and-set too, which is a much wider change than a rename cascade
|
|
// and belongs to whoever takes it on.
|
|
// - DELETE-THEN-RESTORE. The ErrNoRows arm returns without taking a row
|
|
// lock, so a linker soft-deleted during the cascade and RESTORED before
|
|
// this transaction commits comes back holding the old title. RestoreDocument
|
|
// takes no rename lock, so nothing serializes it against this.
|
|
//
|
|
// Both are pre-existing and neither is made worse here. They are recorded
|
|
// because the next reader's question is "is the cascade correct now", and the
|
|
// honest answer is "for the direction this bug named".
|
|
func (s *Store) rewriteLinkerCAS(tx *sql.Tx, id, read, rewritten, oldTitle, newTitle, searchTerm string, scanTotal int64) error {
|
|
expected := read
|
|
next := rewritten
|
|
// Total charged by retries so far — see the accumulation below.
|
|
var retriesSpent int64
|
|
for attempt := 0; attempt < cascadeRewriteAttempts; attempt++ {
|
|
res, err := tx.Exec(s.q(`
|
|
UPDATE documents SET content = ?
|
|
WHERE id = ? AND deleted_at IS NULL AND content = ?
|
|
`), next, id, expected)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return nil
|
|
}
|
|
|
|
var current string
|
|
err = tx.QueryRow(s.q(`
|
|
SELECT content FROM documents WHERE id = ? AND deleted_at IS NULL
|
|
`), id).Scan(¤t)
|
|
if err == sql.ErrNoRows {
|
|
// Soft-deleted between the cascade's SELECT and now. Documented
|
|
// normal outcome, not an error.
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The re-read body is a NEW input, supplied by whoever won the race,
|
|
// and it is bounded by nothing this cascade has already checked. Its
|
|
// budget is the cap less what the other linkers are holding, so the
|
|
// aggregate bound holds across retries too — without this, an editor
|
|
// could grow a linker between the scan and the retry and walk the
|
|
// rename straight back into the amplification it was refused for
|
|
// (BUG-2798, codex round 1 P1).
|
|
grownOccurrences := int64(strings.Count(current, searchTerm))
|
|
// ACCUMULATED across attempts, not just this one. Each retry's
|
|
// buffers become unreachable when `expected`/`next` are reassigned
|
|
// below, but unreachable is not the same as reclaimed — the runtime
|
|
// may not have collected them yet, so a run of failures can hold
|
|
// several copies at once (codex round 9). Counting every attempt is
|
|
// the conservative reading, and it errs toward refusing, which is the
|
|
// safe direction for a memory bound. The loop is capped at
|
|
// cascadeRewriteAttempts, so this cannot accumulate without end.
|
|
retriesSpent += cascadeRetainedBytes(current, grownOccurrences, oldTitle, newTitle)
|
|
grown := retriesSpent
|
|
if scanTotal+grown > MaxRenameCascadeRetainedBytes {
|
|
// Report the AGGREGATE, not this body alone. The bodies the scan
|
|
// counted are still held, so the operation's real size is their
|
|
// total plus the re-read — and reporting only `grown` produced a
|
|
// refusal that contradicted itself, telling the caller it would
|
|
// hold 16 MiB against a 32 MiB limit (codex round 8). A refusal
|
|
// whose own numbers do not justify it reads as a bug in the
|
|
// server, which is the opposite of what an actionable error does.
|
|
return newRenameCascadeTooLargeError(newTitle, scanTotal+grown)
|
|
}
|
|
|
|
// A concurrent edit won. Rewrite ITS body rather than ours: replaying
|
|
// the original rewrite would reintroduce the very content this bug is
|
|
// about losing. If that edit already removed the link, ReplaceTitle is
|
|
// a no-op and the next attempt writes an identical body — which still
|
|
// affects one row and terminates.
|
|
expected = current
|
|
next = links.ReplaceTitle(current, oldTitle, newTitle)
|
|
}
|
|
|
|
// Exhaustion fails the RENAME, rather than leaving this 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 (either the title moves and its links follow,
|
|
// or neither). The user can retry a failed rename; nobody goes looking for
|
|
// a stale wiki-link.
|
|
//
|
|
// Wrapped around ErrLinkCascadeContention so the HTTP layer can tell this
|
|
// from a genuine fault. It is CONTENTION, not a bug: retrying can succeed,
|
|
// and a 500 would tell the caller the opposite (codex round 2).
|
|
return fmt.Errorf("%w: document %s after %d attempts", ErrLinkCascadeContention, id, cascadeRewriteAttempts)
|
|
}
|
|
|
|
func (s *Store) DeleteDocument(id string) error {
|
|
ts := now()
|
|
result, err := s.db.Exec(s.q(`
|
|
UPDATE documents SET deleted_at = ?, updated_at = ?, status = 'archived'
|
|
WHERE id = ? AND deleted_at IS NULL
|
|
`), ts, ts, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) RestoreDocument(id string) (*models.Document, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Read the soft-deleted document's content + workspace inside the tx so
|
|
// we can re-stamp its attachment references before it becomes live again.
|
|
// GetDocument filters deleted_at IS NULL, so it can't see this row yet.
|
|
var content, workspaceID string
|
|
if err := tx.QueryRow(s.q(`
|
|
SELECT content, workspace_id FROM documents WHERE id = ? AND deleted_at IS NOT NULL
|
|
`), id).Scan(&content, &workspaceID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// BUG-2629: re-assert the document's attachment references at the moment
|
|
// it becomes live again. While archived, the live AttachmentReferenced
|
|
// scan can't see this document's refs (BUG-2614 only scans live docs), so
|
|
// the orphan GC may have let their last_referenced_at go stale; a claim
|
|
// racing this restore keys on that stamp, not the live scan. Stamp BEFORE
|
|
// the deleted_at clear, in this tx, per stampAttachmentRefsTx's ORDERING
|
|
// note: the stamp's row-lock makes a concurrent claim block until commit
|
|
// and re-evaluate against the fresh stamp — refusing. Document refs are
|
|
// necessarily never-attached (no document_id column on attachments), so
|
|
// this is the leg with nothing else standing between it and the claim.
|
|
// Prevention only: an already-reclaimed blob is gone (stamp matches zero
|
|
// rows).
|
|
if err := stampAttachmentRefsTx(tx, s, workspaceID, content); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ts := now()
|
|
result, err := tx.Exec(s.q(`
|
|
UPDATE documents SET deleted_at = NULL, updated_at = ?, status = 'draft'
|
|
WHERE id = ? AND deleted_at IS NOT NULL
|
|
`), ts, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetDocument(id)
|
|
}
|
|
|
|
func scanDocuments(rows *sql.Rows) ([]models.Document, error) {
|
|
var docs []models.Document
|
|
for rows.Next() {
|
|
var d models.Document
|
|
var createdAt, updatedAt string
|
|
var pinned bool
|
|
if err := rows.Scan(
|
|
&d.ID, &d.WorkspaceID, &d.Title, &d.Slug, &d.Content, &d.DocType, &d.Status, &d.Tags,
|
|
&pinned, &d.SortOrder, &d.CreatedBy, &d.LastModifiedBy, &d.Source,
|
|
&createdAt, &updatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
d.Pinned = pinned
|
|
d.CreatedAt = parseTime(createdAt)
|
|
d.UpdatedAt = parseTime(updatedAt)
|
|
docs = append(docs, d)
|
|
}
|
|
return docs, rows.Err()
|
|
}
|