Skip to content

Add findOrVersionArtifact to close the tenant+title+kind race - #7

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-5013-artifact-find-or-version
Aug 2, 2026
Merged

Add findOrVersionArtifact to close the tenant+title+kind race#7
TheGreatAxios merged 3 commits into
mainfrom
cl-5013-artifact-find-or-version

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

The schema's only uniqueness is (artifactId, version). Nothing constrains (tenantId, title, kind), so the common "find by title, create if absent, add a version if present" pattern races between the read and the write — every consumer that needs it has to hand-roll locking (one already did, with a database advisory lock wrapping the lookup and write).

  • Adds findOrVersionArtifact(db, args): the atomic primitive for that pattern, closing the race inside the package.
  • Locking: one transaction, serialized on a transaction-scoped advisory lock keyed by hashtext(tenantId, kind, title), in its own lock-space namespace (the two-int4-argument form of pg_advisory_xact_lock) so it can never collide with runArtifactMigrations's lock (single-bigint-argument form — Postgres guarantees the two spaces are disjoint).
  • Refactors writeArtifactVersion into a tx-scoped reviseArtifactVersion core (shared by both the revise route and the new helper) with no change to writeArtifactVersion's public contract, and extracts findArtifactByTitle's query into a selectArtifactByTitle shared helper.

Collision semantics (defined, not left to whoever gets there first)

Whichever concurrent caller acquires the advisory lock first creates the artifact. Every other concurrent caller for the identical (tenantId, kind, title) blocks on the lock, then — once it can proceed — finds the row the first caller just committed and revises it instead of creating a second one. Two overlapping callers always converge on ONE artifact with two versions, never two rows with the same title. Callers for a different tenant, kind, or title never contend with each other. Archived and skill-draft matches are invisible to the lookup (same as findArtifactByTitle), so a fresh artifact is created rather than reviving or adopting one.

Why a helper, not a uniqueness constraint

A hard UNIQUE (tenant_id, title, kind) constraint is stronger, but it's a migration against existing data this package cannot inspect for duplicates — a migration that fails partway through a production deploy over pre-existing duplicate (title, kind) rows is a worse outage than the race it closes (see 0003_schema_invariants's null-tenant guard for the shape of a migration that fails safely on bad data; a dedup migration has no such safe failure mode — it would need to decide which duplicate wins, and that's a product decision, not a migration's). The advisory-lock helper closes the race immediately for anything that adopts it, with no migration risk. If a future audit confirms tenants are duplicate-free, a follow-up migration can still add the hard constraint — the helper's serialization would make it a no-op for any writer already going through it. Full writeup in ARCHITECTURE.md's new "Find-or-version" note.

Scope

Confined to src/artifacts.ts, src/index.ts (new exports), tests, and docs — no changes to the mount surface, options, or identity, so there's no semantic conflict with PR #5. There is real textual overlap, though: both PRs touch src/artifacts.ts, src/artifacts.test.ts, src/index.ts, and all three doc files (ARCHITECTURE.md, CHANGELOG.md, README.md). Whichever merges second will need a real rebase, not just a fast-forward. No HTTP route or agent-tool surface added; that's separate-ticket scope per CL-5013.

Verification

Did not run the suite locally — the environment forbids bun install/builds (OOM risk). Added describe("find-or-version", ...) to src/artifacts.test.ts covering: create-when-absent, revise-when-present (single row, version bumps), kind isolation (same title + different kind creates a second artifact), archived matches never silently revived, the two-caller race — concurrent findOrVersionArtifact calls for the identical triple converge on one artifact with two versions — and a five-caller version of the same race, asserting they all converge on one artifact with five versions. Relying on CI.

Closes CL-5013

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Sharpened the ARCHITECTURE.md/CHANGELOG.md rationale for skipping the (tenant_id, title, kind) constraint per greybeard review: the real reason isn't just 'can't verify legacy data' — createArtifact is a public, unconditional insert (import route, uploads, artifact_link_file) that never dedupes by title, so duplicate titles are a normal, ongoing outcome of ordinary use, not just something to clean up once. A hard constraint would break normal creates, not just gate a backfill.

The schema's only uniqueness is (artifactId, version); nothing constrains
(tenantId, title, kind), so "find by title, create if absent, add a version
if present" raced between the read and the write whenever a consumer
hand-rolled it. findOrVersionArtifact closes that race inside the package
with a transaction-scoped advisory lock keyed on (tenantId, kind, title),
in its own lock-space namespace so it never collides with the migration
runner's lock.

Collision semantics: the caller that acquires the lock first creates the
artifact; every other concurrent caller for the same triple blocks, then
finds and revises the row the first caller just committed. Concurrent
callers always converge on one artifact, never two.

A uniqueness constraint on (tenant_id, title, kind) was considered and
rejected for now: there is no way to confirm existing tenants are free of
duplicate (title, kind) rows, and a migration that fails on real data is
worse than the race it would close. Documented in ARCHITECTURE.md.

CL-5013
The prior wording leaned on "can't verify existing data" as if this were a
one-time backfill problem. It isn't: createArtifact is a public,
unconditional insert used directly by the import route, uploads, and
artifact_link_file, none of which dedupe by title. Two independent creates
sharing a title is normal on every one of those paths, so a hard
UNIQUE(tenant_id, title, kind) constraint would reject ordinary inserts, not
just gate a legacy cleanup. Uniqueness on that triple is a property of the
find-or-version pattern, not an invariant of the table.

CL-5013
…sion note, add N-caller test

- createArtifact's doc comment now says it intentionally skips the title
  lookup, names its three legitimate duplicate-title callers, and points to
  findOrVersionArtifact for callers that want convergence instead.
- Splits the find-or-version ARCHITECTURE.md paragraph's nested parenthetical
  aside into plain sentences; content unchanged.
- Adds a five-caller concurrency test alongside the existing two-caller one,
  asserting they all converge on one artifact with five versions.
@TheGreatAxios
TheGreatAxios force-pushed the cl-5013-artifact-find-or-version branch from 3257bdc to 4e8e1c0 Compare August 2, 2026 18:41
@TheGreatAxios
TheGreatAxios merged commit dc435ba into main Aug 2, 2026
1 check passed
@TheGreatAxios
TheGreatAxios deleted the cl-5013-artifact-find-or-version branch August 2, 2026 18:43
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