fix(audit): prune expired events in bounded batches - #605
Conversation
prune_locked() issued a single open-ended DELETE while holding the global database mutex, and it is reached from the audit insert path. That is harmless in the steady state, where the 365-day default means the statement matches nothing -- but the moment it has real work to do, one ordinary audit write blocks every other database user until the delete finishes. Two ways to get there: a backlog finally aging past the retention window, or an administrator lowering audit_retention_days on an existing table. Measured on a 5.9M-row audit_events table with the service stopped (nothing competing for the lock), deleting 5.33M rows took 2m31s -- long enough to stall recording, detection and the HTTP API together. Delete in capped batches under a wall-clock budget instead, so the mutex hold time is bounded no matter how large the backlog or how slow the storage. When the budget expires with rows still eligible, the caller shortens the next automatic prune interval so a backlog drains steadily rather than one batch per hour. Uses the "id IN (SELECT ... LIMIT ?)" form rather than "DELETE ... LIMIT", which needs SQLITE_ENABLE_UPDATE_DELETE_LIMIT and is not available in every SQLite build this project links against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
I recommend addressing the query plan and regression coverage before merging. Reviewed at
Two behavior claims also need qualification:
Validation: a fresh isolated build of this PR passed |
Addresses review feedback on PR opensensor#605. The batch query ordered by id, which made SQLite choose SCAN audit_events for the inner select instead of the retention index. That regressed the common case -- nothing expired -- into a full walk of every surviving row with the global database mutex held. Reproduced independently on 1M unexpired rows: 0.000s unbounded, 0.048s with ORDER BY id, 0.001s with ORDER BY occurred_at, id. EXPLAIN QUERY PLAN confirms SCAN vs SEARCH USING COVERING INDEX idx_audit_events_occurred. The batch SQL moves to AUDIT_PRUNE_BATCH_SQL so the test asserts the plan of the exact statement the implementation runs, rather than a copy that can drift. Two new tests, both verified to fail against the specific mutation they guard: - test_prune_batch_query_uses_the_retention_index populates a table with nothing expired and asserts the plan uses the covering index and does not scan. Restoring ORDER BY id fails it. - test_prune_stops_on_budget_and_resumes_until_drained forces the budget to zero so exactly one batch is deleted per pass, then asserts partial deletion, the shortened backlog cadence, full drain, and the return to the hourly cadence. Substituting the original unbounded DELETE fails it with "Expected 2000 Was 4000" -- the previous test passed under that substitution, which was the gap raised in review. Budget and interval are reachable through test-only seams declared in the test file rather than the public header, matching the resolution of the same point on PR opensensor#595. Also corrects two overstated claims: the budget is checked between statements, so it bounds how many batches a pass runs rather than promising a maximum hold time; and the shortened interval only makes the next prune eligible sooner, since passes are driven from the audit insert path and a quiet install will hold a backlog until writes resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — all four points were correct, and the query-plan one was a genuine regression I introduced in the case that matters most. Fixed in 1. Full-table scan. Confirmed independently before changing anything. and timing on 1M unexpired rows, same shape as yours:
Changed to New test A timing assertion would have been flaky in CI, and deletion counts alone can't distinguish a scan from a seek — hence asserting on the plan. 2. The test didn't protect the batching. You're right, and I'd rather have caught that myself. I reproduced your mutation: substituting the unbounded Added The budget setter and interval getter are declared 3. Soft budget. Corrected. The header and function comments now say the budget is checked between statements, so it bounds how many batches a pass runs rather than the pass itself — a batch already in flight can overrun it — and explicitly promise no maximum hold time regardless of storage speed. The PR description overstated this too. 4. Eligibility interval, not a schedule. Corrected in both Verification: 🤖 Generated with Claude Code |
|
@davlaw I think we can make it configurable by an admin which audit entries are required, and we can explore additional optimizations for the noisy ones. |
There was a problem hiding this comment.
🟡 Changes recommended
The wall-clock mutex bound is not guaranteed, and automatic cadence plus test-cleanup coverage remain incomplete.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR replaces unbounded audit-event pruning with indexed, budgeted batches to reduce database mutex contention.
Changes:
- Deletes events in capped 2,000-row batches with a 250 ms budget.
- Retries more frequently while a backlog remains.
- Adds pruning, draining, and query-plan tests.
File summaries
| File | Summary |
|---|---|
tests/unit/test_audit_log.c |
Adds pruning behavior and query-plan regression tests. |
src/database/db_audit.c |
Implements bounded pruning and backlog cadence. |
include/database/db_audit.h |
Defines prune limits and batch SQL. |
Review details
Suppressed comments (2)
src/database/db_audit.c:254
- The new cadence branch is only exercised through
db_audit_prune()in the added tests. No test drivesdb_audit_append()after a backlog prune to verify thatmore_remainingchanges the insert-path eligibility from one hour to 60 seconds and then restores it after catch-up, so this production-critical path could regress while the suite stays green. Add a deterministic clock/interval seam and exercise the automatic caller.
if (rc == SQLITE_DONE && now - last_automatic_prune_at >= automatic_prune_interval) {
int deleted = 0;
bool more_remaining = false;
if (prune_locked(db, &deleted, &more_remaining) == 0) {
last_automatic_prune_at = now;
tests/unit/test_audit_log.c:236
- This sets a process-global prune-budget override, but the reset at line 256 is skipped whenever any assertion below line 236 returns early. A failed test can therefore leave later tests running with a zero budget and altered pruning cadence; reset the seam unconditionally from the test fixture teardown or an unconditional cleanup path.
db_audit_set_prune_budget_ms_for_testing(0);
- Files reviewed: 3/3 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.
| * to the backlog. The budget is checked between statements, so it bounds how | ||
| * many batches a pass runs, not the pass itself -- one batch already in | ||
| * flight can overrun it, and no maximum hold time is promised. When the | ||
| * budget runs out with work still pending, more_remaining is set so the | ||
| * caller can come back sooner instead of waiting out the full interval. |
There was a problem hiding this comment.
Agreed — the budget is checked only after sqlite3_step() returns, so it can't bound a single batch that is slow on I/O or waiting on a busy timeout. I've narrowed the guarantee rather than trying to enforce it inside the statement: the code comments already said the budget "bounds how many batches a pass runs, not the pass itself" and that "no maximum hold time is promised", and the PR description, which still claimed a bound "regardless of storage speed", now says explicitly that it is not a wall-clock bound on mutex hold time.
What the change does guarantee is a row-count cap per statement (2,000 via the indexed occurred_at, id ordering) plus a between-statement budget, so work per pass is proportional to the batch size instead of the backlog. Interrupting a statement mid-flight via a progress handler would roll back that batch's work and needs its own retry/backoff design, so I've left it out of scope here.
…earDown Addresses the second Copilot review round on PR opensensor#605. Production pruning is driven from db_audit_append(), but the cadence transition was only exercised through db_audit_prune(). Add test_automatic_prune_on_append_follows_backlog_cadence, which places the eligibility window with a new test-only seam and asserts, through appends: a budget-limited pass shortens the interval to 60s, an append inside that window does not prune, an append past it runs the next batch, and a pass that finds nothing restores the hourly cadence. Verified it fails when the insert path ignores more_remaining ("Expected 60 Was 3600"). The budget override is process-global, and the test that set it only reset it on its success path, so a failed assertion could leak a zero budget into later tests. tearDown() now resets the budget override and the automatic prune state unconditionally. The PR description is also corrected to stop describing the budget as a wall-clock bound on mutex hold time: it is checked between statements, so a batch in flight or a slow/contended database can overrun it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Second Copilot round addressed in Wall-clock bound (inline). Narrowed the claim rather than enforcing it mid-statement; replied on the thread. The description no longer promises a hold-time bound. Insert-path cadence untested (suppressed). Production pruning runs from Seam leak on assertion failure (suppressed). The budget override was only reset on the test's success path.
On your earlier suggestion of letting admins choose which audit entries are required: happy to take that on in a separate PR if you'd like it from me, but I don't want to duplicate it if you're already planning it. 🤖 Generated with Claude Code |
|
@davlaw I have family in town so won't have time to work on it for a bit ... feel free to give it a shot! |
Addresses the second half of #604.
Problem
prune_locked()issued a single open-ended statement:"DELETE FROM audit_events WHERE occurred_at < ?;"while holding the global database mutex — and it is reached from the audit insert path (
db_audit.c:182, the hourly automatic prune), not from a background job.Today that's harmless, because the 365-day default means the statement matches nothing on essentially every install. It stops being harmless the moment it has real work:
audit_retention_dayson an existing table…at which point one ordinary audit write blocks every other database user until the delete completes.
I measured this on the 5.9M-row
audit_eventstable from #604, with the service stopped so nothing was competing for the lock:Two and a half minutes of held mutex, reachable from a routine audit write. On that install it would have stalled recording, detection and the HTTP API simultaneously.
Change
Delete in capped batches (
AUDIT_PRUNE_BATCH_ROWS= 2000) under a soft time budget (AUDIT_PRUNE_BUDGET_MS= 250), so the work per pass is proportional to the batch size rather than to the backlog. The budget is checked between statements, so it limits how many batches a pass runs — it is not a wall-clock bound on mutex hold time: a batch already in flight, or a slow/contended database, can overrun it.When the budget expires with rows still eligible,
prune_locked()reportsmore_remainingand the caller shortens the insert-path eligibility interval from 1 hour to 60s, so a backlog drains steadily instead of one batch per hour. This is not a scheduled job: passes are driven bydb_audit_append(), so an install with no audit traffic holds a backlog until writes resume. Once caught up it returns to the hourly cadence, leaving steady-state behaviour unchanged.The batch statement uses
id IN (SELECT ... ORDER BY id LIMIT ?)rather thanDELETE ... LIMIT, since the latter requiresSQLITE_ENABLE_UPDATE_DELETE_LIMIT, which isn't enabled in every SQLite build this project links against.idx_audit_events_occurredcovers the inner scan.One deliberate behaviour change worth calling out: the explicit prune behind the retention-settings endpoint is now bounded by the same budget, so on a large backlog it returns a partial
pruned_eventscount and the remainder drains over the following ticks. That seemed clearly preferable to an admin request that freezes the database for minutes, but say the word if you'd rather that path stayed unbounded.Testing
test_retention_drains_large_backlog_without_touching_live_eventsbuilds a backlog larger than one batch plus events inside the retention window, drains it through repeated prunes, and asserts the backlog is fully removed, the live events are untouched, and the loop terminates.Verified it actually fails on a broken implementation rather than passing vacuously — inverting the cutoff comparison to
occurred_at >= ?produces:It's tagged with its own
target_uuidso it neither depends on nor disturbs rows left by other tests in the binary.What it does not assert is the mutex hold time itself — that's inherently timing-dependent, so the tests cover correctness, termination, batching, the query plan and the insert-path cadence rather than a time bound.
test_audit_log15/15,test_db_backupandtest_api_handlers_recordings_playbackalso green, full target builds clean.Not included
The
live.viewvolume question from #604 (a durable audit row per live-view request, ~400k rows/day on the install that surfaced this) is left alone — that's a call about what the audit trail is meant to guarantee, not something to change from the outside. This PR only makes pruning safe whenever it does run.🤖 Generated with Claude Code