fix(audit-wave-10): concurrency hardening (concurrency-auditor)
Close the CRITICAL + HIGH-tractable race conditions the concurrency-auditor flagged. The wide-impact items (BullMQ jobId plumbing — C-2; webhook outbound retry idempotency keys; etc.) span too many call sites for a single contained wave and stay deferred. **C-1 — handleDocumentCompleted concurrent-retry orphan-blob** Wave 1 fixed the compensating-delete on single-process failure but the idempotency gate at line 1110 reads `doc.status` outside any row lock. Two webhook deliveries arriving in parallel both pass the gate, both storage.put + db.insert(files), and the losing files row orphans its blob since documents.signed_file_id only points at one. Now the transaction at line 1176 SELECTs the document `FOR UPDATE` and re-checks the gate; if a concurrent worker already completed, throws a sentinel `DocumentAlreadyCompletedError` which the outer catch recognizes and runs the compensating storage.delete at info level (not error). Net effect: at-most-once signed-PDF persistence even under Documenso 5xx-then-retry storms. **H-1 — moveFolder cycle check race** Two concurrent folder moves (A → B and B → A) in READ COMMITTED can each pass the cycle check against pre-state and both commit, leaving A↔B in the tree. Add a per-port `pg_advisory_xact_lock` at the top of the move transaction so the walk-and-write is atomic per port. Lock auto-releases on tx end; no impact on cross-port folder ops. **H-3 — upsertInterestBerth 23505 → generic 500** Two concurrent `setPrimaryBerth` calls hit `idx_interest_berths_one_primary` and the loser surfaced as a generic 500. Catch the 23505 + constraint name and remap to ConflictError so the UI gets a "Another rep changed the primary berth at the same time. Refresh and try again." toast. **M-2 — username uniqueness 23505 → generic 500** Same TOCTOU shape: pre-check at me/route.ts:132 says "available", the UPDATE then fails at the partial unique index. Catch 23505 + `idx_user_profiles_username_unique` and remap to ConflictError. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ import { and, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { CodedError, NotFoundError } from '@/lib/errors';
|
||||
import { CodedError, ConflictError, NotFoundError } from '@/lib/errors';
|
||||
import type { AuditMeta } from '@/lib/audit';
|
||||
|
||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
@@ -183,9 +183,31 @@ export async function upsertInterestBerth(
|
||||
berthId: string,
|
||||
opts: AddOrUpdateOpts = {},
|
||||
): Promise<InterestBerth> {
|
||||
return db.transaction(async (tx) => {
|
||||
return upsertInterestBerthTx(tx, interestId, berthId, opts);
|
||||
});
|
||||
// concurrency-auditor H-3: two concurrent setPrimaryBerth calls on
|
||||
// the same interest hit `idx_interest_berths_one_primary` (partial
|
||||
// unique on `is_primary=true`). The loser surfaced as a generic
|
||||
// 500 because the 23505 wasn't translated. Catch and remap to a
|
||||
// ConflictError so the UI gets a "another rep just changed the
|
||||
// primary berth" toast instead.
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
return upsertInterestBerthTx(tx, interestId, berthId, opts);
|
||||
});
|
||||
} catch (err) {
|
||||
if (isPrimaryBerthConflict(err)) {
|
||||
throw new ConflictError(
|
||||
'Another rep changed the primary berth at the same time. Refresh and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function isPrimaryBerthConflict(err: unknown): boolean {
|
||||
if (typeof err !== 'object' || err === null) return false;
|
||||
// postgres.js surfaces the constraint name in `constraint_name`.
|
||||
const e = err as { code?: string; constraint_name?: string };
|
||||
return e.code === '23505' && e.constraint_name === 'idx_interest_berths_one_primary';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user