Skip to content

fix(resolver): recognise Postgres unique-violation errors (HIGH from 24h review)#161

Closed
sroussey wants to merge 1 commit into
mainfrom
claude/wonderful-hypatia-m7eldh
Closed

fix(resolver): recognise Postgres unique-violation errors (HIGH from 24h review)#161
sroussey wants to merge 1 commit into
mainfrom
claude/wonderful-hypatia-m7eldh

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

Summary

PRs #158 + #160 made the canonical-entity resolvers multi-process race-safe by combining storage-level UNIQUE indexes on (resolver_version, cik) (and crd_number for company) with a UNIQUE-rejection retry path in PersonResolver / CompanyResolver. That retry path depends on isUniqueConstraintError(err) recognising the storage layer's unique-violation.

The bug

isUniqueConstraintError only recognised 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 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" (SQLSTATE unique_violation).
  • Case-insensitive substring "duplicate key value violates unique constraint" in message (belt-and-suspenders — matches even if a wrapper layer drops code).
  • SQLITE_CONSTRAINT_UNIQUE native-error code from better-sqlite3.
  • The existing SQLite/InMemory message form, now case-insensitive (mirrors SqliteQueueStorage's pattern).

Defensive type guards handle non-Error inputs (null, primitives, missing fields). No instanceof pg.DatabaseError / instanceof SqliteError — neither package is a direct sec dependency.

Affected: every sec deployment using SEC_DB_TYPE=postgres. SponsorFamilyResolver / UnderwriterFamilyResolver are thin wrappers over CompanyResolver and 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 (23503 FK, 23502 NOT NULL, 23514 CHECK), 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 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. Company additionally parameterises over key: "cik" | "crd" so the ..._uniq_resolver_version_crd_number constraint-name form is exercised. The inline ad-hoc .startsWith("UNIQUE constraint failed") filters in the helpers are replaced by calls to isUniqueConstraintError so 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

  • The instance-scoped mutex (separate concern; MEDIUM finding).
  • findByResolverAndName ordering (LOW finding).
  • Any production behaviour beyond the matcher.

Verified assumptions

  • PostgresTabularStorage._putInternal (/home/user/libs/providers/postgres/src/storage/PostgresTabularStorage.ts:789-795) is a pure passthrough from this.db.query — pg errors propagate with code / message intact.
  • isUniqueConstraintError is imported only by PersonResolver and CompanyResolver — no surprising consumers.
  • The pattern matches the precedent in SqliteQueueStorage (case-insensitive regex on UNIQUE constraint failed plus SQLITE_CONSTRAINT_UNIQUE code).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FyzGHzzrdUSP5ViGzqox13


Generated by Claude Code

`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

Copy link
Copy Markdown
Contributor Author

Closing in favor of the consolidated #163, which includes this change (commit 1) alongside the fixes from #162.


Generated by Claude Code

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.

2 participants