research.md nails down the exact mechanism for each real race the audit found: a partial unique index for assignment double-assignment, a Ticket-style version counter for SLA pause/resume/sweep, and a partial unique index for escalation-rule idempotency — each traced to the specific repository/service code that has the gap today. data-model.md and plan.md carry the resulting schema and repository-contract changes; quickstart.md defines the real-infra verification steps for each user story. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4.0 KiB
Data Model: Load and Concurrency Testing
All changes below are additive to existing models — no existing column is removed or
retyped, and no existing consumer (012-admin-list-views, 015-reporting-dashboards) needs any
change, since none of them write to Assignment/SLARun/EscalationEvent directly (all writes
already go through the repositories being changed here).
SLARun (existing model, one new field)
| Field | Type | Notes |
|---|---|---|
version |
Int @default(0) |
NEW. Optimistic-concurrency counter, identical convention to Ticket.version (003-ticketing). Incremented on every successful updateWithVersion call. |
Migration: additive ALTER TABLE sla_runs ADD COLUMN version INTEGER NOT NULL DEFAULT 0; —
every existing row defaults to 0, which is exactly the version any in-flight or future
updateWithVersion call expects for a run nobody has updated since this migration ran.
Assignment (existing model, no column change — one new index)
New raw partial unique index (Prisma schema DSL cannot express a partial predicate directly, so this is added via a raw-SQL migration step, same approach already used elsewhere in this project for Postgres-specific constraints):
CREATE UNIQUE INDEX assignments_one_current_per_ticket
ON assignments (ticket_id)
WHERE is_current = true;
Enforces at the database level: a ticket may have at most one Assignment row with
isCurrent = true at any moment, closing the race research.md §1 describes. The existing
non-unique @@index([ticketId, isCurrent]) is unaffected and stays for the repository's own
findCurrent lookup.
EscalationEvent (existing model, no column change — one new index)
CREATE UNIQUE INDEX escalation_events_ticket_rule_unique
ON escalation_events (ticket_id, rule_id)
WHERE rule_id IS NOT NULL;
Enforces at the database level: a given rule may fire at most once per ticket over that
ticket's lifetime (manual escalations, where rule_id IS NULL, are explicitly excluded and
remain repeatable). Closes the race research.md §3 describes.
Repository contract changes
SlaRunRepository
update(id, data)→ replaced byupdateWithVersion(id, expectedVersion, data): Promise<SLARun | null>, mirroringTicketsRepository.updateStatus's exact shape: an atomicupdateMany({where:{id, version: expectedVersion}, data:{...data, version:{increment:1}}}), returning the fresh row on success (count === 1) ornullon a stale-version mismatch. Every existing call site (pause,resume,complete,runBreachDetectionSweep) is updated to pass its own last-readversionand to retry (re-read + recompute + re-call) up to 3 times on anullresult before giving up silently (matching the sweep's own existing no-throw, best-effort style — these are internal transitions with no HTTP caller waiting on a 409).
AssignmentRepository
createAssignment(data)— same signature and return type; internally catches a PrismaP2002on the newassignments_one_current_per_ticketindex and retries the entire transaction (bounded to 3 attempts) before rethrowing.
EscalationEventRepository
create(data)— same signature; internally catches a PrismaP2002on the newescalation_events_ticket_rule_uniqueindex and returns the pre-existing row for that(ticketId, ruleId)pair (afindFirst({where:{ticketId, ruleId}})fallback) instead of throwing, soEscalationService.fire's caller sees a normalEscalationEventeither way — a duplicate trigger is invisible to the caller, not an error.
Test-only entities (not persisted — in-memory test scaffolding)
- Load test report (
tests/load/):{ endpoint: string; connections: number; durationSec: number; requestsPerSec: number; latencyP50Ms: number; latencyP90Ms: number; latencyP99Ms: number; non2xxCount: number; rateLimitedCount: number }— printed to console and written as JSON undertests/load/reports/<endpoint>-<timestamp>.json(gitignored) for each run, satisfying FR-008's separation of rate-limited responses from genuine failures.