Correction (see comment below): the claim in "Why it's unbounded in practice" that db_audit_prune() is never called on a schedule is wrong — it runs hourly from the audit-insert path (db_audit.c:182). The real issue is that retention is time-only with a 365-day default, so the hourly prune deletes zero rows indefinitely at this event rate. A separate latent bug (unbounded DELETE holding the global DB mutex, measured at 2m31s for 5.3M rows) is described in the comment.
On a 7-camera production install, audit_events reached 5,946,202 rows / ~4.06 GB — 95% of a 4.27 GB database — and the resulting write volume made sqlite3_backup_step()-based scheduled backups unable to complete at all. No scheduled backup succeeded in 24+ hours.
Composition
dbstat on a 4.27 GB database:
audit_events 2908 MB
idx_audit_events_target 386 MB
sqlite_autoindex_audit_events_1 286 MB
idx_audit_events_action_outcome 240 MB
idx_audit_events_occurred 130 MB
idx_audit_events_principal 107 MB
-------
4057 MB (95% of the database)
By action:
live.view 5,913,081 (99.4%)
system.admin 29,791
recordings.replay 1,763
storage.configure 939
snapshot.create 552
auth.login 26
Daily live.view volume, accelerating:
2026-09-08 216,537
2026-09-09 311,059
2026-09-10 218,235
2026-09-11 335,016
2026-09-12 417,029 (partial day)
Data goes back only to 2026-08-24 — 19 days to 4 GB.
Why it's unbounded in practice
Two things combine:
audit_retention_days defaults to 365 (AUDIT_RETENTION_DEFAULT_DAYS, and the system_settings seed in the embedded migrations).
db_audit_prune() is never called on a schedule. Its only call site in the tree is src/web/api_handlers_audit.c:303, inside the retention-settings update handler — so pruning happens only as a side effect of an admin explicitly PATCHing the retention setting. There is no timer, no startup prune, no periodic maintenance hook.
So on any install where nobody manually pokes that endpoint, the table grows forever. At ~400k rows/day, 365-day retention is effectively infinite — the disk gives out long before the retention window does.
Each live.view insert also writes 6 B-trees (table + 5 indexes), so the write amplification is substantial.
Why this breaks backups
SQLite restarts an online backup from page one whenever the source database is modified through a different connection than the backup's. db_backup.c opens its own read-only source connection, so every audit insert counts. With live.view writing continuously, sqlite3_backup_step() never reaches the end of the database: the destination plateaus (~800 MB in our case) and the loop re-copies indefinitely at ~100% CPU until something aborts it.
Measured on the same 4.27 GB database:
|
result |
| Scheduled online backup, cameras live |
never completes (47 attempts, 0 successes in 24h) |
Cold .backup via sqlite3 CLI, service stopped |
6.4 seconds, quick_check = ok |
Backup success rate degraded as the table grew — roughly 5-7 successes/day against an expected 24 over the preceding week, then 0.
Secondary effect: a failed attempt restarts ~1 second later, so a core is pinned continuously.
Suggested directions
- Don't write a durable audit row per
live.view request. It's a high-frequency read with little forensic value at per-request granularity — sampling, coalescing per session/interval, or dropping it entirely would remove 99.4% of the volume.
- Run
db_audit_prune() on a schedule (startup + periodic), rather than only as a side effect of a settings write.
- Reconsider the 365-day default, or make retention size-aware — a row-count/byte ceiling would bound this regardless of event rate.
Happy to open a PR for (2) and/or (3) if you have a preference on shape — (1) seems like your call on what the audit trail is meant to guarantee.
🤖 Generated with Claude Code
On a 7-camera production install,
audit_eventsreached 5,946,202 rows / ~4.06 GB — 95% of a 4.27 GB database — and the resulting write volume madesqlite3_backup_step()-based scheduled backups unable to complete at all. No scheduled backup succeeded in 24+ hours.Composition
dbstaton a 4.27 GB database:By action:
Daily
live.viewvolume, accelerating:Data goes back only to 2026-08-24 — 19 days to 4 GB.
Why it's unbounded in practice
Two things combine:
audit_retention_daysdefaults to 365 (AUDIT_RETENTION_DEFAULT_DAYS, and thesystem_settingsseed in the embedded migrations).db_audit_prune()is never called on a schedule. Its only call site in the tree issrc/web/api_handlers_audit.c:303, inside the retention-settings update handler — so pruning happens only as a side effect of an admin explicitly PATCHing the retention setting. There is no timer, no startup prune, no periodic maintenance hook.So on any install where nobody manually pokes that endpoint, the table grows forever. At ~400k rows/day, 365-day retention is effectively infinite — the disk gives out long before the retention window does.
Each
live.viewinsert also writes 6 B-trees (table + 5 indexes), so the write amplification is substantial.Why this breaks backups
SQLite restarts an online backup from page one whenever the source database is modified through a different connection than the backup's.
db_backup.copens its own read-only source connection, so every audit insert counts. Withlive.viewwriting continuously,sqlite3_backup_step()never reaches the end of the database: the destination plateaus (~800 MB in our case) and the loop re-copies indefinitely at ~100% CPU until something aborts it.Measured on the same 4.27 GB database:
.backupvia sqlite3 CLI, service stoppedquick_check= okBackup success rate degraded as the table grew — roughly 5-7 successes/day against an expected 24 over the preceding week, then 0.
Secondary effect: a failed attempt restarts ~1 second later, so a core is pinned continuously.
Suggested directions
live.viewrequest. It's a high-frequency read with little forensic value at per-request granularity — sampling, coalescing per session/interval, or dropping it entirely would remove 99.4% of the volume.db_audit_prune()on a schedule (startup + periodic), rather than only as a side effect of a settings write.Happy to open a PR for (2) and/or (3) if you have a preference on shape — (1) seems like your call on what the audit trail is meant to guarantee.
🤖 Generated with Claude Code