fix(resolver): recognise Postgres unique-violation errors (HIGH from 24h review)#161
Closed
sroussey wants to merge 1 commit into
Closed
fix(resolver): recognise Postgres unique-violation errors (HIGH from 24h review)#161sroussey wants to merge 1 commit into
sroussey wants to merge 1 commit into
Conversation
`isUniqueConstraintError` previously only matched the SQLite/InMemory message form `"UNIQUE constraint failed"`. Under `SEC_DB_TYPE=postgres`, `PostgresTabularStorage._putInternal` propagates the raw `pg.DatabaseError` unmodified — it carries `code: "23505"` (SQLSTATE `unique_violation`) and a message of the form `"duplicate key value violates unique constraint <name>"`. Neither was recognised, so the UNIQUE-rejection retry path in `PersonResolver` / `CompanyResolver` never fired under Postgres: the multi-process race that PRs #158 + #160 made safe on SQLite would re-throw and abort the filing on Postgres instead of converging on the winner. Broaden the matcher to additionally recognise (a) `code === "23505"` (SQLSTATE) and (b) a case-insensitive substring `"duplicate key value violates unique constraint"` in `message`. Belt-and-suspenders: matching both signals keeps the helper working if a future wrapper layer drops one of them. Also normalise the SQLite/InMemory message check to be case-insensitive (mirroring `SqliteQueueStorage`'s existing pattern) and recognise the `SQLITE_CONSTRAINT_UNIQUE` native-error code. Defensive type guards: `err` may be `null`, a primitive, or an object missing `code` / `message`; read both safely before testing. No `pg` / `better-sqlite3` `instanceof` checks — neither package is a direct dependency of `@workglow/sec`, and string/code matching is robust to wrapped or re-thrown errors. Affected: every `sec` deployment using `SEC_DB_TYPE=postgres`. The family-tier resolvers (`SponsorFamilyResolver`, `UnderwriterFamilyResolver`) are thin wrappers over `CompanyResolver` and inherit the fix transitively. Tests: - `isUniqueConstraintError.test.ts` (new): 12 unit tests covering both backends' message + code shapes, case sensitivity, unrelated PG codes (FK / NOT NULL / CHECK), unrelated text, and non-Error inputs (null / undefined / primitives / missing fields). - `PersonResolver.race.test.ts` / `CompanyResolver.race.test.ts`: extract a `runMultiProcessRace({ errorShape })` helper that translates the storage's natural UNIQUE rejection into the chosen shape, then run the twin-instance scenario under both `"sqlite"` and `"pg"` shapes (plus CRD coverage for company, exercising the `..._uniq_resolver_version_crd_number` constraint-name form). Inline ad-hoc `.startsWith("UNIQUE constraint failed")` filters in the test helpers now call the shared `isUniqueConstraintError` to kill drift between test and production matchers. Out of scope: the instance-scoped mutex (separate concern), the `findByResolverAndName` ordering (LOW finding), and any production-side behaviour beyond the matcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyzGHzzrdUSP5ViGzqox13
Contributor
Author
|
Closing in favor of the consolidated #163, which includes this change (commit 1) alongside the fixes from #162. Generated by Claude Code |
11 tasks
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.
Summary
PRs #158 + #160 made the canonical-entity resolvers multi-process race-safe by combining storage-level
UNIQUEindexes on(resolver_version, cik)(andcrd_numberfor company) with a UNIQUE-rejection retry path inPersonResolver/CompanyResolver. That retry path depends onisUniqueConstraintError(err)recognising the storage layer's unique-violation.The bug
isUniqueConstraintErroronly recognised the SQLite / InMemory message form"UNIQUE constraint failed". UnderSEC_DB_TYPE=postgres,PostgresTabularStorage._putInternalpropagates the rawpg.DatabaseErrorunmodified — it carriescode: "23505"(SQLSTATEunique_violation) and a message of the form"duplicate key value violates unique constraint <name>". Neither signal was recognised, so under Postgres the retry path never fired: the multi-process race made safe on SQLite would re-throw and abort the filing instead of converging on the winner's canonical id.The fix
Broaden the matcher to additionally recognise:
code === "23505"(SQLSTATEunique_violation)."duplicate key value violates unique constraint"inmessage(belt-and-suspenders — matches even if a wrapper layer dropscode).SQLITE_CONSTRAINT_UNIQUEnative-error code frombetter-sqlite3.SqliteQueueStorage's pattern).Defensive type guards handle non-Error inputs (
null, primitives, missing fields). Noinstanceof pg.DatabaseError/instanceof SqliteError— neither package is a directsecdependency.Affected: every
secdeployment usingSEC_DB_TYPE=postgres.SponsorFamilyResolver/UnderwriterFamilyResolverare thin wrappers overCompanyResolverand inherit the fix transitively (verified via grep — no direct imports of the helper).Test coverage
isUniqueConstraintError.test.ts(new) — 12 unit tests covering both backends' message + code shapes, case sensitivity, unrelated PG codes (23503FK,23502NOT NULL,23514CHECK), unrelated text ("connection refused", empty, partial phrase), and non-Error inputs (null / undefined / primitives / missing fields).PersonResolver.race.test.ts/CompanyResolver.race.test.ts— extract arunMultiProcessRace({ errorShape })helper that translates the storage's natural UNIQUE rejection into the chosen shape, then run the twin-instance scenario under both"sqlite"and"pg"shapes. Company additionally parameterises overkey: "cik" | "crd"so the..._uniq_resolver_version_crd_numberconstraint-name form is exercised. The inline ad-hoc.startsWith("UNIQUE constraint failed")filters in the helpers are replaced by calls toisUniqueConstraintErrorso test and production matchers cannot drift.Verification:
bun test src/resolver/→ 59 pass / 0 fail (was 55 pre-change; 4 new tests from the PG-shape parameterisation + CRD coverage, plus 12 new unit tests in a separate file).bun test src/storage/canonical/→ 70 pass / 0 fail.npx tsc --noEmit→ clean.Out of scope
findByResolverAndNameordering (LOW finding).Verified assumptions
PostgresTabularStorage._putInternal(/home/user/libs/providers/postgres/src/storage/PostgresTabularStorage.ts:789-795) is a pure passthrough fromthis.db.query— pg errors propagate withcode/messageintact.isUniqueConstraintErroris imported only byPersonResolverandCompanyResolver— no surprising consumers.SqliteQueueStorage(case-insensitive regex onUNIQUE constraint failedplusSQLITE_CONSTRAINT_UNIQUEcode).🤖 Generated with Claude Code
https://claude.ai/code/session_01FyzGHzzrdUSP5ViGzqox13
Generated by Claude Code