Skip to content

Stop a second article with the same title becoming the first one - #223

Merged
ssavutu merged 2 commits into
mainfrom
fix-duplicate-article-titles
Aug 28, 2026
Merged

Stop a second article with the same title becoming the first one#223
ssavutu merged 2 commits into
mainfrom
fix-duplicate-article-titles

Conversation

@ssavutu

@ssavutu ssavutu commented Aug 28, 2026

Copy link
Copy Markdown
Member

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, and clientFoundRows reports 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 same mod_date to 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-2 from 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/:slug links 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:

  • create held its dedicated slug-lock connection across the pooled writes that follow the insert
  • the detail read held its result rows open across the author lookup

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-67 pair and a Summit story someone had manually annotated "(the duplicate)"). Both live letters kept their URLs; only shadowed rows moved. scripts/report_duplicate_slugs.py is the tool that found them and is report-only by default.

Note that #10182 still 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

  • New integration test walks the exact report end to end: same title, blank slug, then asserts the new article opens as itself and saving it leaves the old one untouched
  • Concurrency test: four simultaneous creates of one title get four distinct slugs
  • Coverage for rename-collision 409, re-saving your own slug, excerpt derivation reading the patched row, and deterministic resolution of a slug two rows still share
  • Full server suite green against MariaDB 11.7, go vet clean, frontend tsc / vitest / eslint clean
  • Swagger regenerated with the pinned swag@v1.16.6

Reviewer 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

ssavutu and others added 2 commits August 28, 2026 01:35
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
@ssavutu
ssavutu merged commit c8b7ecf into main Aug 28, 2026
7 checks passed
@ssavutu
ssavutu deleted the fix-duplicate-article-titles branch August 28, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant