fix(sec/d24): concurrency + workflow isolation
Red-team correctness findings: - SqliteDb.transaction now serializes top-level transactions with an async mutex and tracks re-entrancy via AsyncLocalStorage (nested calls -> savepoints, no deadlock). The old shared txnDepth counter let concurrent async callers interleave BEGIN/COMMIT on the one connection -> silent data loss; a 50-way concurrent read-modify-write test now lands exactly. - generateRecurring wraps each plan in try/catch: one misconfigured plan is parked (audited 'generate_failed') and the scan continues, instead of aborting all remaining generation and the entire auto-send drain, every scan. - POST /modules/:id/notify broadcast IIFE gets a trailing catch so a mid-loop throw answers the request instead of hanging it. Full suite 388 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
a7b3ade22e
commit
aaad1e7a3a
@ -0,0 +1,46 @@
|
|||||||
|
// apps/hq/test/concurrency.test.ts — D24 (red-team): SQLite transaction serialization.
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { openDb } from '../src/db'
|
||||||
|
|
||||||
|
describe('SqliteDb.transaction serialization (D24)', () => {
|
||||||
|
it('overlapping transactions do not interleave — a concurrent counter stays consistent', async () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await db.exec(`CREATE TABLE ctr (id INTEGER PRIMARY KEY, n INTEGER NOT NULL)`)
|
||||||
|
await db.run(`INSERT INTO ctr (id, n) VALUES (1, 0)`)
|
||||||
|
// 50 read-modify-write transactions fired concurrently. Without serialization the
|
||||||
|
// read-then-write would race and lose increments; the mutex makes the final count exact.
|
||||||
|
const bump = () => db.transaction(async () => {
|
||||||
|
const row = (await db.get<{ n: number }>(`SELECT n FROM ctr WHERE id=1`))!
|
||||||
|
// Yield the event loop mid-transaction — the moment a naive impl would interleave.
|
||||||
|
await Promise.resolve()
|
||||||
|
await db.run(`UPDATE ctr SET n=? WHERE id=1`, row.n + 1)
|
||||||
|
})
|
||||||
|
await Promise.all(Array.from({ length: 50 }, bump))
|
||||||
|
expect((await db.get<{ n: number }>(`SELECT n FROM ctr WHERE id=1`))!.n).toBe(50)
|
||||||
|
await db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a nested transaction still commits with its parent (savepoint), and rolls back alone on inner failure', async () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await db.exec(`CREATE TABLE t (k TEXT PRIMARY KEY)`)
|
||||||
|
// Outer commits; a caught inner failure rolls back only the savepoint.
|
||||||
|
await db.transaction(async () => {
|
||||||
|
await db.run(`INSERT INTO t (k) VALUES ('outer')`)
|
||||||
|
await db.transaction(async () => {
|
||||||
|
await db.run(`INSERT INTO t (k) VALUES ('inner')`)
|
||||||
|
throw new Error('inner boom')
|
||||||
|
}).catch(() => undefined)
|
||||||
|
})
|
||||||
|
expect(await db.get(`SELECT k FROM t WHERE k='outer'`)).toBeDefined()
|
||||||
|
expect(await db.get(`SELECT k FROM t WHERE k='inner'`)).toBeUndefined()
|
||||||
|
// An outer failure rolls back everything including the nested savepoint work.
|
||||||
|
await db.transaction(async () => {
|
||||||
|
await db.run(`INSERT INTO t (k) VALUES ('a')`)
|
||||||
|
await db.transaction(async () => { await db.run(`INSERT INTO t (k) VALUES ('b')`) })
|
||||||
|
throw new Error('outer boom')
|
||||||
|
}).catch(() => undefined)
|
||||||
|
expect(await db.get(`SELECT k FROM t WHERE k='a'`)).toBeUndefined()
|
||||||
|
expect(await db.get(`SELECT k FROM t WHERE k='b'`)).toBeUndefined()
|
||||||
|
await db.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue