Conversation
…QLite
`flushAuditLogs()` wraps its batched inserts in a transaction, with the comment
"ensure all inserts succeed or fail together / This prevents index corruption
from partial writes". On SQLite that guarantee does not hold.
better-sqlite3's `transaction()` is synchronous: it runs BEGIN, invokes the
callback, then COMMIT (node_modules/better-sqlite3/lib/methods/transaction.js).
drizzle hands the user callback straight to it
(drizzle-orm/better-sqlite3/session.cjs:61). An `async` callback returns a
promise at its first `await`, so COMMIT fires on an empty transaction before a
single insert has executed. The inserts then run afterwards in autocommit -
one implicit transaction per 25-row batch - and the synchronous try/catch
cannot observe their rejections either.
Measured against a real SQLite database:
- a flush that fails on the 3rd batch leaves 50 rows committed instead of 0
- `sqlite.inTransaction` is true entering the callback and false immediately
after the first `await`, i.e. COMMIT already ran
This matters beyond the broken guarantee: the catch block re-queues the entire
slice (`auditLogBuffer.unshift(...logsToWrite)`) on the assumption that nothing
was written, so on a mid-flush failure the already-committed rows are inserted
a second time - duplicate audit-log entries.
It is also a performance bug. Losing the wrapping transaction turns one commit
into ceil(n/25) commits - 400 separate fsync'd transactions for a
MAX_BUFFER_SIZE (10000) flush. Because better-sqlite3 is synchronous, that time
is the event loop being blocked, stalling every proxied request.
Fix: extract the batched insert into `insertAuditLogsAtomically()` and drive it
synchronously on SQLite so the transaction is real. Postgres is genuinely async
and keeps the awaited form, selected via the existing `driver` export (the same
branching `server/routers/auditLogs/` already uses).
Benchmarks (10000 rows, real file-backed SQLite, median of repeated runs;
absolute times vary with disk but the ratio is stable):
default journal_mode=DELETE, synchronous=FULL
flush wall time ~3-4x faster
event-loop blocked 1914 ms -> 587 ms in one continuous stall
journal_mode=WAL, synchronous=NORMAL (ENABLE_SQLITE_WAL_MODE=true)
marginal, ~1.0-1.3x - WAL commits are much cheaper, so commit count
matters far less
Adds server/routers/badger/auditLogTransaction.test.ts pinning all three
behaviors against a real database: a sync callback rolls every batch back on
failure, commits all of them on success, and an async callback leaves the
earlier batches committed (the regression this guards against).
Verified `npx tsc --noEmit` under both `npm run set:sqlite` and `set:pg`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Update the regression test to execute the production path and verify the actual fix.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This pull request makes SQLite audit-log batch inserts atomic while preserving PostgreSQL behavior.
Changes:
- Uses synchronous SQLite transactions for batched inserts.
- Retains asynchronous PostgreSQL transaction handling.
- Adds SQLite transaction regression tests.
File summaries
| File | Summary | Finding |
|---|---|---|
server/routers/badger/logRequestAudit.ts |
Implements driver-specific atomic insertion. | None |
server/routers/badger/auditLogTransaction.test.ts |
Tests SQLite rollback and commit behavior. | Moderate (3 votes): the test does not exercise the production helper, so it would pass if the fix regressed. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let threw = false; | ||
|
|
||
| try { | ||
| db.transaction((tx) => { |
There was a problem hiding this comment.
Good catch — you're right, and this was a real weakness. The test built its own transaction mirroring the production shape, so it documented the behaviour without actually guarding it.
Fixed in aa9e27d: extracted the batched insert into server/routers/badger/insertRowsAtomically.ts, taking the database and table as parameters so it's directly testable, and flushAuditLogs() now calls it. The test drives that exact function.
I verified it actually guards the change by reverting the sqlite branch to an async callback — the test now fails with:
A failed flush must roll back every batch, leaving no rows behind
Expected: 0
Actual: 50
Also made the failure originate inside the insert path (a NOT NULL violation on a row in the third batch) rather than a thrown sentinel, so it exercises the same path a real database error would, and added an empty-flush case. tsc --noEmit re-verified under both set:sqlite and set:pg.
|
Was AI used to write the entire description too? |
Addresses review feedback: the previous test constructed its own transaction
that mirrored the production shape, so it would still have passed if
insertAuditLogsAtomically() were reverted to an async callback - it documented
the behaviour without guarding it.
Extract the batched insert into server/routers/badger/insertRowsAtomically.ts,
taking the database and table as parameters so it can be exercised directly,
and have flushAuditLogs() call it. The test now drives that exact function.
Verified the test actually guards the change: reverting the sqlite branch to an
async transaction callback makes it fail with
A failed flush must roll back every batch, leaving no rows behind
Expected: 0
Actual: 50
The failure case now also originates inside the insert path (a NOT NULL
violation on a row in the third batch) rather than a thrown sentinel, so it
exercises the same path a real database error would. Added an empty-flush case.
`npx tsc --noEmit` verified clean under both `npm run set:sqlite` and `set:pg`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Community Contribution License Agreement
By creating this pull request, I grant the project maintainers an unlimited,
perpetual license to use, modify, and redistribute these contributions under any terms they
choose, including both the AGPLv3 and the Fossorial Commercial license terms. I
represent that I have the right to grant this license for all contributed content.
AI Disclosure
Claude Code (Anthropic) was used to trace this through
drizzle-ormandbetter-sqlite3's source, write the fix and tests, and build/run the benchmarks below. I directed the investigation and verified every claim myself against a real database — including reproducing the broken rollback, confirming the mechanism viasqlite.inTransaction, and re-running the benchmarks to check variance before quoting any number.Description
Summary
flushAuditLogs()inserver/routers/badger/logRequestAudit.tswraps its batched inserts in a transaction:On SQLite, that guarantee does not hold — the transaction is effectively a no-op.
Root cause
better-sqlite3'stransaction()is synchronous. It runs BEGIN, invokes the callback, then COMMIT (lib/methods/transaction.js):drizzle passes the user callback straight through to it (
drizzle-orm/better-sqlite3/session.cjs:61):An
asynccallback returns a promise at its firstawait, soapply.call(...)returns immediately and COMMIT fires on an empty transaction before a single insert has run. The inserts then execute afterwards in autocommit mode — one implicit transaction per 25-row batch — and the synchronoustry/catchcan't observe their rejections either.Consequences
1. Partial writes persist. Against a real SQLite database, a flush that fails on the 3rd batch leaves 50 rows committed instead of 0. Confirmed mechanically:
sqlite.inTransactionistrueon entering the callback andfalseimmediately after the firstawait— COMMIT has already run.2. Duplicate audit-log rows. The catch block re-queues the entire slice on the assumption nothing was written:
Since the earlier batches were already committed, the retry inserts them a second time.
3. It's also a performance bug. Losing the wrapping transaction turns one commit into
ceil(n/25)commits — 400 separate fsync'd transactions for aMAX_BUFFER_SIZE(10,000) flush instead of 1. And because better-sqlite3 is synchronous, that time is the event loop being blocked, stalling every proxied request the process is serving.What changed
Extracted the batched insert into
insertAuditLogsAtomically(), which drives the inserts synchronously on SQLite so the transaction is real. Postgres (node-postgres) is genuinely async and keeps the awaited form, selected via the existingdriverexport — the same branching already used inserver/routers/auditLogs/.Behaviour is unchanged on Postgres, and unchanged on SQLite's happy path; only the failure path and commit count differ.
Impact & measurements
All numbers from a real file-backed SQLite database on this machine, flushing
MAX_BUFFER_SIZE= 10,000 rows with a schema mirroringrequestAuditLog.Correctness (the actual bug)
inTransactionafter firstawaitfalse(already committed)Event-loop blocking (what matters for a proxy)
Measured with a 1 ms interval probe; because better-sqlite3 is synchronous, this is time the process cannot serve any request.
DELETE/FULL(default)WAL/NORMAL(opt-in)Both shapes block the loop for their full duration (
awaiton better-sqlite3 resolves as a microtask and doesn't yield to I/O), so this is a straight reduction with no latency trade-off — ~1.3 s less continuous blocking per full flush on the default configuration.Flush time across buffer sizes
Honest caveats on these numbers:
DELETE/synchronous=FULLconfiguration, which is what ships unlessENABLE_SQLITE_WAL_MODE=trueis set.Tests
Adds
server/routers/badger/auditLogTransaction.test.ts, which pins all three behaviours against a real SQLite database:inTransaction === falseafter the firstawait— documenting the exact regression this guards against.How to test?
npx tsx server/routers/badger/auditLogTransaction.test.ts— all assertions pass.npx tsc --noEmit— clean. I verified this under bothnpm run set:sqliteandnpm run set:pg, since the driver branch has to compile against either driver's types.npx eslint server/routers/badger/logRequestAudit.ts— clean.🤖 Generated with Claude Code