fix(shortener): retry id generation on same-second create collisions - #325
Merged
Conversation
…earch (PPT-2644) Adds a generated, stored tsvector column + GIN index to every table whose rest-api index route is searchable, replacing the Elasticsearch write path. 'simple' text-search config on both sides (no stemming — entity names, identifiers, emails; matches the guests.tsv_search precedent). Secrets and encrypted content are deliberately excluded from every vector. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Elasticsearch's analyzer segmented uri/file-path fields, so their parts were individually searchable — PG 'simple' keeps '/path' as one lexeme. Split on punctuation (keeping the raw value) for mod.uri, driver.file_name/default_uri, repo.uri, shortener.uri and signage_plugin.uri. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t migration master's 9ff188d renumbered the AI-support migration to 20260806100500000, the exact version this migration was using. Micrate versions must be unique, and the search-vector migration must sort after everything on master, so it moves to 20260810100500000. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shortener#short_id derives the primary key from the creation second plus rand(63), i.e. only 63 possible ids per wall-clock second, so two same-second creates collide on shortener_pkey with probability ~1/63 and the caller gets a 500 (PgORM::Error::RecordNotSaved). The compact format is deliberate (the id is the public short URL), so keep it and retry the insert instead: each attempt re-runs the before_create callback which rolls a fresh random component. Bounded at 10 attempts; retries are cheap single-row inserts. Surfaced by rest-api short_url_spec failing ~1 in 2 runs once the ES-free spec harness (PPT-2644) removed the inter-create ES sleeps, but the defect is pre-existing and also affects concurrent production creates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Stacking note: based on the M1 branch tip so downstream PPT-2644 verification picks up both changes together. Two ways to land it — either keep it as its own small PR opened after M1 merges (it will then show only the one commit), or cherry-pick / fold
4710f0ainto M1. The fix itself has no dependency on the FTS work; it stacks purely for verification convenience. It could equally be rebased onto master as a standalone PR if you want it to land first.The defect (pre-existing, unrelated to PPT-2644)
Shortener#short_id(src/placeos-models/shortener.cr) generates the primary key in abefore_create:The creation second fills all but the low 6 bits, and
rand(63)fills those — so there are only 63 possible ids per wall-clock second. Two creates in the same second collide onshortener_pkeywith probability ~1/63, and the caller gets an unhandledPgORM::Error::RecordNotSaved(duplicate key value violates unique constraint "shortener_pkey") → a 500 from rest-api.Surfaced by PPT-2644 verification: the ES-free rest-api spec harness no longer sleeps between creates, so
spec/controllers/short_url_spec.cr(which creates 2 shorteners back-to-back in two tests plus the CRD helper) started failing ~1 run in 2. It is also a real production bug for concurrent shortener creation — burst QR/short-URL provisioning will 500 at ~1/63 per same-second pair.The fix
The compact id format is deliberate (the id is the public short URL), so it is kept byte-for-byte identical. Instead,
Shorteneroverridessave!to retry on exactly this collision:save!loops callingsuper, rescuingPgORM::Error::RecordNotSavedonly when the record is stillnew_record?and the message namesshortener_pkey; anything else re-raises immediately.__createre-runs thebefore_createcallbacks on every attempt (run_create_callbacks→__before_create, verified in active-model's callback macro), so each retry rolls a freshrand(63)— no extra regeneration code needed.CREATE_ID_ATTEMPTS = 10; a failed attempt is one cheap single-row INSERT round-trip.save!covers every path:savedelegates tosave!(this is what rest-api's controller uses —url.save),create!/createcallrecord.save!, and the specs call.save!directly.update!also routes throughsave!but can never satisfy thenew_record?guard. Same override pattern asEdge#save!/Survey::Question#save!.Spec
spec/shortener_spec.cr: freezes time with Timecop and creates 16 shorteners — with the timestamp component pinned, all 16 draw from the same 63 ids, so at least one collision is near-certain (~88%; probabilistic by design,randdeliberately not stubbed). Asserts all 16 persist with unique ids and that the format is unchanged (uri-prefix + base62).Without the fix this scenario is the rest-api failure amplified: on the old code the batch 500s almost every run.
Evidence
spec/controllers/short_url_spec.crfailed ~1 in 2 full-harness runs (VERIFICATION-2026-08-10.md, "rest-api short_url triage":short_url_spec:6duplicate key … shortener_pkey)../test spec/shortener_spec.cr×3: 3/3 green (3 examples, 0 failures each).uri-5o05sg×3,uri-5o05sl×2,uri-5o05sc×2) each absorbed by a retry, 0 surfaced errors../test spec/controllers/short_url_spec.cr×4 withshard.override.ymltemporarily pointing placeos-models at this branch: 4/4 green (previously ~1 in 2 failed). Override reverted afterwards; rest-api worktree left untouched.