Skip to content

fix(audit): make the audit-log flush transaction actually atomic on SQLite - #3777

Open
aithal007 wants to merge 2 commits into
fosrl:mainfrom
aithal007:fix/audit-log-transaction-atomicity
Open

aithal007 wants to merge 2 commits into
fosrl:mainfrom
aithal007:fix/audit-log-transaction-atomicity

Conversation

@aithal007

Copy link
Copy Markdown

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-orm and better-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 via sqlite.inTransaction, and re-running the benchmarks to check variance before quoting any number.

Description

Summary

flushAuditLogs() in server/routers/badger/logRequestAudit.ts wraps its batched inserts in a transaction:

// Use a transaction to ensure all inserts succeed or fail together
// This prevents index corruption from partial writes
await logsDb.transaction(async (tx) => {
    const BATCH_DB_SIZE = 25;
    for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
        await tx.insert(requestAuditLog).values(logsToWrite.slice(i, i + BATCH_DB_SIZE));
    }
});

On SQLite, that guarantee does not hold — the transaction is effectively a no-op.

Root cause

better-sqlite3's transaction() is synchronous. It runs BEGIN, invokes the callback, then COMMIT (lib/methods/transaction.js):

before.run();                               // BEGIN
try {
    const result = apply.call(fn, this, arguments);
    after.run();                            // COMMIT

drizzle passes the user callback straight through to it (drizzle-orm/better-sqlite3/session.cjs:61):

const nativeTx = this.client.transaction(transaction);

An async callback returns a promise at its first await, so apply.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 synchronous try/catch can'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.inTransaction is true on entering the callback and false immediately after the first await — COMMIT has already run.

2. Duplicate audit-log rows. The catch block re-queues the entire slice on the assumption nothing was written:

auditLogBuffer.unshift(...logsToWrite);

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 a MAX_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 existing driver export — the same branching already used in server/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 mirroring requestAuditLog.

Correctness (the actual bug)

Scenario Before After
Flush fails on 3rd batch 50 rows committed 0 rows (rolled back)
Happy path 60/60 rows 60/60 rows (unchanged)
inTransaction after first await false (already committed) n/a — stays in one real transaction
Commits per 10,000-row flush 400 1

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.

journal mode wall (ms) event loop blocked (ms) worst single stall (ms)
DELETE / FULL (default) before 1914 2036 1914
after 588 708 587
WAL / NORMAL (opt-in) before 749 869 749
after 580 675 581

Both shapes block the loop for their full duration (await on 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

rows commits before before (ms) after (ms) speedup
100 4 22.5 8.2 2.8×
500 20 91.3 22.1 4.1×
1,000 40 172.1 41.2 4.2×
5,000 200 1062.1 299.8 3.5×
10,000 400 1736.5 390.8 4.4×

Honest caveats on these numbers:

  • Absolute times vary noticeably run-to-run (disk/fsync noise). I ran the suite twice; the 10,000-row "before" figure came out as 3803 ms and 1736 ms on the two runs. The speedup ratio is the stable quantity — consistently ~3–4× across both runs.
  • WAL mode sees little benefit (~1.0–1.3×, occasionally within noise), because WAL commits are far cheaper so commit count matters much less. The large win applies to the default DELETE/synchronous=FULL configuration, which is what ships unless ENABLE_SQLITE_WAL_MODE=true is set.
  • The correctness fix applies regardless of journal mode.

Tests

Adds server/routers/badger/auditLogTransaction.test.ts, which pins all three behaviours against a real SQLite database:

  1. a synchronous callback rolls every batch back on failure (0 rows),
  2. it still commits all batches on success (60 rows),
  3. an async callback leaves the earlier batches committed and shows inTransaction === false after the first await — documenting the exact regression this guards against.

How to test?

  1. npx tsx server/routers/badger/auditLogTransaction.test.ts — all assertions pass.
  2. npx tsc --noEmit — clean. I verified this under both npm run set:sqlite and npm run set:pg, since the driver branch has to compile against either driver's types.
  3. npx eslint server/routers/badger/logRequestAudit.ts — clean.

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings September 16, 2026 14:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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) => {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@AstralDestiny

Copy link
Copy Markdown
Contributor

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants