Stop a second article with the same title becoming the first one - #223
Merged
Conversation
Creating an article whose title already existed filed a second row on the same slug. POST derived the slug from the title with no uniqueness check, and only the dashboard's quick-draft path deduped -- client-side, with a probe loop the full editor's New Article form never ran. Every screen then addressed articles by slug alone, so opening the new article loaded the older one, and the autosave that followed wrote over both: UPDATE ... WHERE `slug` = ? matches every duplicate, and clientFoundRows reports the match count, so nothing looked wrong. Production carried two letters in exactly that state, one of them with its body replaced by a letter from 2022. Reserve the slug server-side on create. The MySQL named lock is held on the candidate rather than on the stem it was derived from: two creates can reach the same candidate from different stems -- one titled "Foo" that becomes foo-2, one whose slug is literally foo-2 -- and a lock on the stem would let both insert it. The response now carries the id and the slug that were actually written, because the server may not have stored the one it was sent. Address articles by id everywhere else. The editor routes carry /articles/:id/:slug/edit, the write endpoints accept ?id=, and a request that arrives with a slug alone resolves to a single row -- preferring the caller's archive state, then the lowest id -- before it reads, locks or writes. One rule now governs the edit lease, the mutation lock, the excerpt derivation and the public detail read, so the row a request locks is the row it reads and the row it writes. Legacy /articles/:slug links keep working and resolve the same way. Renaming a slug onto one another article holds answers 409 rather than quietly recreating the collision. Two deadlocks surfaced while testing this, both the same shape: a handler waiting on the connection pool while holding part of it. Create kept its dedicated slug-lock connection across the pooled writes that follow the insert, and the detail read kept its result rows open across the author lookup. Neither is reachable at the production pool size; both hang outright at the single connection the integration tests use, which is why the create path had no working test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WuLVSbPvAr2o4m7CqzLEVu
The fix stops new collisions; it does not repair the rows that predate it. Slugs are not unique in the corpus -- the index is deliberately non-UNIQUE, see EnsureArticlesSlugIndex -- so finding the affected articles is a query somebody has to run, and deciding which of a pair keeps the permalink is a rule somebody has to pick. Report-only by default: it prints each shared slug, which row currently serves it, and which rows are shadowed. The row that keeps the bare slug is the one the site serves today -- lowest id among the non-archived rows, or the lowest id overall if the whole group is archived -- which is what GetArticle resolves to, so the repair never moves a URL a reader or a search engine already has. Only rows that had no working permalink get renamed, and they take the first free numeric suffix, the same rule the create path uses. Archived rows are included. They keep their slug in the trash and would collide again the moment someone restores one. Run against production 2026-08-28: five shared slugs across 10187 articles, two of them a published letter shadowing a draft filed the same day. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WuLVSbPvAr2o4m7CqzLEVu
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.
Reported by the newsroom: an editor filed "Letter from the editor", and the article they made became a duplicate of a letter published years earlier. Not simultaneous, and not the slug counter failing — two different creation paths, only one of which deduped.
What was happening
The dashboard's quick-draft probes for a free slug before POSTing. The full editor's New Article form does not: it sends an empty slug, and the server derived one from the title with no uniqueness check at all, so a repeated title filed a second row on the same slug.
From there every screen addressed articles by slug alone. Opening the new article loaded the older one, and the autosave three seconds later wrote over both —
UPDATE ... WHERE slug = ?matches every duplicate, andclientFoundRowsreports the match count, so nothing surfaced an error.Production had two letters in exactly that state.
#8212(published 2022) and#10182(draft, created that day) shared a slug, the samemod_dateto the second, and byte-identical 6540-character bodies — one autosave had written the 2022 letter into both rows.The fix
Slugs are reserved server-side on create, under a MySQL named lock held on the candidate rather than on the stem it came from — two creates can converge on
foo-2from different stems, and a lock on the stem would let both take it. The 201 now returns the id and the slug actually written, since the server may not have stored the one it was sent.Articles are addressed by id everywhere else. Editor routes carry
/articles/:id/:slug/edit, write endpoints accept?id=, and a request arriving with a slug alone resolves to one row — preferring the caller's archive state, then lowest id — before it reads, locks, or writes. One rule now governs the edit lease, the mutation lock, the excerpt derivation, and the public detail read, so the row a request locks is the row it reads and the row it writes. Legacy/articles/:sluglinks keep working and resolve identically. Renaming onto a slug another article holds returns 409 instead of recreating the collision.Two deadlocks found while testing
Both the same shape — a handler waiting on the connection pool while holding part of it:
Neither is reachable at the production pool size of 50; both hang outright at the single connection the integration tests use. That is why the create path had no working test — the one that existed had never run against a database, and failed on a column the test schema was missing.
Production data
Already repaired, separately from this branch: five shared slugs across 10,187 articles — the two letters above, plus three pairs already in the trash (including a
duptest-67pair and a Summit story someone had manually annotated "(the duplicate)"). Both live letters kept their URLs; only shadowed rows moved.scripts/report_duplicate_slugs.pyis the tool that found them and is report-only by default.Note that
#10182still holds the 2022 letter's title and body — reachable now at its own slug, but its original content was overwritten before any of this and is only recoverable from a backup.Testing
go vetclean, frontendtsc/vitest/eslintcleanswag@v1.16.6Reviewer note
One deliberate UX change beyond the bug: creating from the full editor now lands on the new article's edit page instead of the articles list, so a deduplicated slug is visible rather than silently different. Easy to revert if you'd rather keep the list.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WuLVSbPvAr2o4m7CqzLEVu