Skip to content

audit_events grows unbounded (live.view, ~400k rows/day) and starves the online DB backup #604

Description

@davlaw

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:

  1. audit_retention_days defaults to 365 (AUDIT_RETENTION_DEFAULT_DAYS, and the system_settings seed in the embedded migrations).
  2. 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

  1. 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.
  2. Run db_audit_prune() on a schedule (startup + periodic), rather than only as a side effect of a settings write.
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions