Skip to content

Root and reply threads: dedupe and enforce a unique key - #435

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-7130-root-and-reply-threads-are-select-then-insert-with-no-unique
Aug 29, 2026
Merged

TheGreatAxios merged 3 commits into
mainfrom
cl-7130-root-and-reply-threads-are-select-then-insert-with-no-unique

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Fixes CL-7130 — https://linear.app/abklabs/issue/CL-7130

Problem

ensureRootThread (packages/chat/src/threads.ts:388-419) and
anchoredReplyThread (:454-495) SELECT ... LIMIT 1 then INSERT, and
workbench_threads (packages/chat/src/schema.ts:195-225) only had a
non-unique index on (tenant_id, workbench_id). Two concurrent first
writers could each insert a duplicate kind='root' row for the same
workbench, or a duplicate kind='reply' row for the same
parentMessageId, and the LIMIT 1 read with no ORDER BY then
picked one of the duplicates nondeterministically.

Change

  • New migration 0025_workbench_threads_unique_key (next number after
    0023; skipping 0024 since PR Relaunch live agents when their inference credential rotates #427 already claims it as
    0024_workbench_launch_sources_digest) dedupes any existing
    duplicate root/reply threads, keeping the oldest row per key and
    repointing workbench_thread_messages.thread_id,
    workbench_messages.thread_id, and any workbench_threads.parent_thread_id
    that referenced a dropped duplicate, then adds partial unique indexes:
    UNIQUE (tenant_id, workbench_id) WHERE kind = 'root' and
    UNIQUE (tenant_id, workbench_id, parent_message_id) WHERE kind = 'reply'.
  • Mirrors the indexes in schema.ts via uniqueIndex(...).where(sql\...`)`.
  • ensureRootThread/anchoredReplyThread now insert with
    onConflictDoNothing (targeting the new partial indexes) and
    re-select on conflict — the same pattern reactions.ts'
    toggleReaction already uses — instead of select-then-insert. The
    existing LIMIT 1 reads got a deterministic ORDER BY created_at.

Tests

  • packages/chat/test/migrations.test.ts: asserts the new partial
    indexes exist after a full migration run, and a new test seeds two
    duplicate root threads (with a message pointed at the newer one)
    before 0025 runs, then proves 0025 collapses them onto the oldest
    row and repoints thread membership.
  • packages/chat/test/threads.drizzle.test.ts (new): DB-gated, races
    two concurrent ensureRootThread calls and two concurrent
    openReplyThread calls against a real Postgres pool, proving
    neither throws and both converge on one row.
  • The machine this PR was built on was too loaded to run the full
    bun run check locally (it OOM-killed) — typecheck
    (bunx tsc --noEmit -p packages/chat), the focused test files above
    (unit + DB-gated with E2E_REQUIRED=1), prettier, and eslint on the
    changed files all passed locally; CI is the gate for the rest.

@TheGreatAxios
TheGreatAxios force-pushed the cl-7130-root-and-reply-threads-are-select-then-insert-with-no-unique branch from 30aba3f to ed3979f Compare August 28, 2026 18:55
@TheGreatAxios
TheGreatAxios changed the base branch from main to cl-6687-rotated-api-keys-never-reach-live-agents August 28, 2026 18:55
@TheGreatAxios
TheGreatAxios force-pushed the cl-7130-root-and-reply-threads-are-select-then-insert-with-no-unique branch from ed3979f to 7fdf5bf Compare August 29, 2026 04:59
@TheGreatAxios
TheGreatAxios force-pushed the cl-7130-root-and-reply-threads-are-select-then-insert-with-no-unique branch 2 times, most recently from 901b82e to 7735525 Compare August 29, 2026 17:28
@TheGreatAxios
TheGreatAxios force-pushed the cl-7130-root-and-reply-threads-are-select-then-insert-with-no-unique branch 5 times, most recently from 4242d0a to 5276c77 Compare August 29, 2026 18:12

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critique · comment

Partial unique indexes plus insert-on-conflict-reselect keep one root/reply thread per key.

No uniqueness defect. This branch is stacked on #427; merge order is 427 then 435, or rebase onto main after 427.

  • packages/chat/src/threads.ts — createDeliveryThread is still select-then-insert. Out of CL-7130 scope.

Walking-skeleton red on this PR is main deleting DATABASE_URL after memory-mount tests (CL-7182 / #465), not this uniqueness diff.

Base automatically changed from cl-6687-rotated-api-keys-never-reach-live-agents to main August 29, 2026 22:34
Extends migrations.test.ts to seed two duplicate root threads (with
a message pointed at the newer one) and prove the coming migration
collapses them onto the oldest row, repointing thread membership, and
that the resulting partial unique indexes exist. Adds
threads.drizzle.test.ts, a DB-gated suite (mirroring
reactions.drizzle.test.ts) that races two concurrent ensureRootThread
and openReplyThread calls against a real Postgres connection pool.
ensureRootThread and anchoredReplyThread were select-then-insert with
no unique constraint backing the read, so concurrent first writers
could each insert a thread for the same (workbench, root) or
(workbench, parent message, reply) key, and the LIMIT 1 read with no
ORDER BY picked one nondeterministically.

Migration 0025 dedupes any existing duplicates (keeping the oldest
row, repointing thread membership and message thread pointers off the
dropped rows) and adds partial unique indexes on workbench_threads.
Both stores now insert with onConflictDoNothing and re-select on
conflict (the toggleReaction pattern in reactions.ts), with a
deterministic ORDER BY created_at on the existing reads.

Fixes CL-7130.
@TheGreatAxios
TheGreatAxios force-pushed the cl-7130-root-and-reply-threads-are-select-then-insert-with-no-unique branch from 5276c77 to 2719021 Compare August 29, 2026 22:35

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critique · approve

Partial unique indexes, conflict-safe inserts, and 0025 keep-oldest repoint hold. walking-skeleton ran the DB-gated proofs green on main after #427.

  • packages/chat/src/migrations.ts:462-468 — root/reply unique keys
  • packages/chat/src/threads.ts:431-438 / 537-547 — onConflictDoNothing then re-select
  • File-for-later: createDeliveryThread is still select-then-insert (out of CL-7130)

GitHub will not accept approve on the author's own PR; this comment is the review.

@TheGreatAxios
TheGreatAxios merged commit 5e0f5d6 into main Aug 29, 2026
5 checks passed
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