fix(thread-service): serialize sequence-number assignment per thread#3494
Conversation
The file's own header described its tests as integration tests requiring a running Postgres, and it was guarded with describe.skipIf(!DATABASE_URL). The unit job never sets DATABASE_URL, and the integration job globs only server/tests/integration/, so all 39 tests skipped in both jobs and ran nowhere. Surfaced while auditing #3094's CI fix (#3292): of 60 integration files, 59 run cleanly and 1 (creative-agent-comparison.test.ts) is intentionally env-gated on COMPARE_LIVE=1. The thread-service file was the one quiet anomaly #3094's audit didn't catch — the file lived in unit/, not integration/, so the survey of integration/ coverage missed it. Move is a git mv plus a header refresh. The describe.skipIf stays as defense-in-depth so a developer running this file outside the integration job without DATABASE_URL set sees a clean skip rather than a connection error. server/tests/integration/threads-api.test.ts covers the HTTP API layer; this file covers the service layer. Both run now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
addMessage's SELECT MAX(sequence_number) + 1 ... INSERT pattern under READ COMMITTED has a classic read-modify-write race: two concurrent calls on the same thread observe the same MAX and write the same next value. The (thread_id, sequence_number) index is non-unique so the collision is silent, leaving getThreadMessages's ORDER BY sequence_number ASC nondeterministic between the duplicates. Affects parallel Slack thread posts, webhook bursts, and fast tool-call sequences — exactly the workloads addMessage was built for. Surfaced by the existing concurrent-addMessage integration test once the test file was moved into tests/integration/ where the runner actually picks it up. Take pg_advisory_xact_lock(hashtext(thread_id)) at the top of the transaction. Same pattern as billing/org-intake-lock. Locks only serialize on the same thread; cross-thread inserts run unimpeded. Lock release is automatic on COMMIT/ROLLBACK. Defense-in-depth UNIQUE(thread_id, sequence_number) is intentionally deferred to a follow-up PR — production likely contains rows with duplicate sequence numbers from this code path, and adding the constraint without a dedup pass would break the migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1a41b54 to
24f4eeb
Compare
|
Extended this PR with the actual fix. The file move alone unmasked a real concurrency bug — moving the test was step 1, fixing what it caught is step 2. Bug
Affects parallel Slack thread posts, webhook bursts, and fast tool-call sequences. Fix
DeferredDefense-in-depth Branch rebased onto current |
|
Got it — advisory lock serializes per-thread with no cross-thread contention, and deferring the Generated by Claude Code |
…sage Security review surfaced a lock-wait DoS vector: without lock_timeout the per-thread advisory lock queues waiters indefinitely on a stuck holder, burning pool connections. Mirror billing/org-intake-lock's SET LOCAL pattern with looser values appropriate for a fast INSERT workload (5s lock wait, 10s statement ceiling). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
`server/tests/unit/thread-service.test.ts` was misfiled. Its own header described the tests as integration tests requiring a running Postgres, and it was guarded with `describe.skipIf(!process.env.DATABASE_URL)`. The unit job never sets `DATABASE_URL`, and the integration job globs only `server/tests/integration/`, so all 39 tests skipped in both jobs and ran nowhere.
Why this surfaced now
Audited the actual coverage of #3094's CI fix (#3292) and discovered the gap. Of 60 integration files, 59 run and 1 is intentionally env-gated on `COMPARE_LIVE=1` (`creative-agent-comparison`). The thread-service file was the one quiet anomaly #3094's audit didn't catch — the file lived in `unit/`, not `integration/`, so the survey of `integration/` coverage missed it.
What changed
Coverage gain
39 tests on `ThreadService` (createOrCreateThread, addMessage, listThreads, paging, ON CONFLICT semantics) now actually run on every PR via the post-#3292 `server-integration` job. Previously they existed but couldn't catch any regression because they never executed.
Why `--empty` changeset
Test reorganization, no protocol-spec surface change.
Test plan
🤖 Generated with Claude Code