Add findOrVersionArtifact to close the tenant+title+kind race - #7
Merged
Conversation
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
force-pushed
the
cl-5013-artifact-find-or-version
branch
from
August 2, 2026 18:41
3257bdc to
4e8e1c0
Compare
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
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).findOrVersionArtifact(db, args): the atomic primitive for that pattern, closing the race inside the package.hashtext(tenantId, kind, title), in its own lock-space namespace (the two-int4-argument form ofpg_advisory_xact_lock) so it can never collide withrunArtifactMigrations's lock (single-bigint-argument form — Postgres guarantees the two spaces are disjoint).writeArtifactVersioninto a tx-scopedreviseArtifactVersioncore (shared by both the revise route and the new helper) with no change towriteArtifactVersion's public contract, and extractsfindArtifactByTitle's query into aselectArtifactByTitleshared 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 asfindArtifactByTitle), 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 (see0003_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 inARCHITECTURE.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 touchsrc/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). Addeddescribe("find-or-version", ...)tosrc/artifacts.test.tscovering: 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 — concurrentfindOrVersionArtifactcalls 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