Skip to content

Drop COUNT(*) precheck in HistoryDb::prune_old_entries (perf) #10

Description

@BumpyClock

Problem

HistoryDb::prune_old_entries (crates/core/src/storage/history.rs:223) runs a full-table SELECT COUNT(*) before every retention delete. It is called unconditionally from insert_entry (crates/core/src/storage/history.rs:169), i.e. on every saved history row.

// crates/core/src/storage/history.rs:223
fn prune_old_entries(conn: &Connection, max_rows: i64) -> Result<usize> {
    let count: i64 = conn
        .query_row(
            &format!("SELECT COUNT(*) FROM {HISTORY_TABLE}"),   // line 226
            [],
            |row| row.get(0),
        )
        .map_err(|e| anyhow!("Failed to count history rows: {}", e))?;
    if count <= max_rows {            // line 231: early-out
        return Ok(0);
    }
    let deleted = conn
        .prepare_cached(&Self::retention_prune_sql())?
        .execute(params![max_rows])?;
    Ok(deleted)
}

The DELETE itself (retention_prune_sql, crates/core/src/storage/history.rs:209) already no-ops under the cap, because its subquery selects the deletion tail via LIMIT -1 OFFSET ?1 (line 215) — when row count <= max_rows the OFFSET consumes all rows and the id IN (...) set is empty:

// crates/core/src/storage/history.rs:209
"DELETE FROM {HISTORY_TABLE} \
 WHERE id IN ( \
     SELECT id FROM {HISTORY_TABLE} \
     ORDER BY created_at DESC, id DESC \
     LIMIT -1 OFFSET ?1 \
 )"

So the COUNT(*) is a redundant precheck guarding a statement that is already safe to run when under cap. The doc comment at insert_entry (crates/core/src/storage/history.rs:164-167) describes this skip as deliberate "avoiding wasted work."

Why it matters

  • Runs on the persistence / stop-latency path: one extra SELECT COUNT(*) per saved row, in the same locked connection as the insert.
  • Without an index optimization, COUNT(*) is an O(N) scan of the history table; it grows with retained rows even though the steady state is "at cap, nothing to delete."
  • Low correctness risk to remove — the DELETE's OFFSET tail already encodes the same guard, so removing the count cannot delete rows that are within the cap.

Proposed change

clawpatch direction: delete the count precheck and run the DELETE unconditionally.

  1. In prune_old_entries (crates/core/src/storage/history.rs:223), remove the SELECT COUNT(*) query and the if count <= max_rows { return Ok(0); } early-out; return the row count from execute(params![max_rows]) directly.
  2. Update the insert_entry doc comment (crates/core/src/storage/history.rs:164-167) to drop the "skips the delete entirely when the row count is at or under the cap" claim.
  3. Tests:
    • Keep retention_prune_helper_deletes_oldest_tail_over_cap (:440) — proves over-cap still deletes exactly the overflow and keeps the newest max_rows.
    • Repurpose, do not delete, retention_prune_helper_deletes_zero_when_under_cap (:399): it currently asserts the count-skip detail (helper returns Ok(0) and issues no delete). After the change it must still assert the observable invariant — under/at cap, prune_old_entries returns 0 and the row count is unchanged — but stop implying the delete was skipped.
    • Coupled SQL-shape test retention_prune_sql_deletes_offset_tail (:373) is tracked separately; coordinate the change there (see Traceability).

Recommendation / triage

DISCUSS / lean KEEP. This reverses the STOR-1 perf-review decision; the count guard was an intentional early-out. Both the COUNT(*) and the LIMIT -1 OFFSET ?1 delete subquery are ~O(N) index walks under cap, so removing the guard is roughly a wash and makes a DELETE write-statement execute on every insert (extra WAL/transaction work, vs. a read-only count). Recommend keeping unless profiling shows the COUNT(*) actually dominates the insert path. If simplifying, do it together with the coupled SQL-shape-test removal rather than piecemeal.

Acceptance criteria

  • Decision recorded (keep / remove) with rationale tying back to STOR-1.
  • If removed: prune_old_entries issues no SELECT COUNT(*); the DELETE runs unconditionally and relies on the empty OFFSET tail to no-op under cap.
  • Behavior unchanged — proven by: over-cap deletes exactly the overflow and keeps the newest max_rows (retention_prune_helper_deletes_oldest_tail_over_cap, :440); under/at cap leaves row count untouched and returns 0 (retention_prune_helper_deletes_zero_when_under_cap, :399, updated to assert the invariant not the skip).
  • insert_entry doc comment (:164-167) matches the implemented behavior.
  • No change to public API of HistoryDb.

Traceability

  • clawpatch finding ID: fnd_sig-feat-library-5e03ac577c-544c_4b589bfafd
  • Revalidate: clawpatch revalidate --finding fnd_sig-feat-library-5e03ac577c-544c_4b589bfafd --reasoning-effort high
  • Coupled issue: the SQL-shape assertion test retention_prune_sql_deletes_offset_tail (crates/core/src/storage/history.rs:373). If this change lands, fold the SQL-shape-test cleanup into the same PR; do not remove the count guard independently of that coupling.

Metadata

Metadata

Assignees

No one assigned

    Labels

    deslopifyFound by clawpatch deslopify reviewperformanceCPU/memory/latency efficiencytech-debtCode quality / maintainability cleanup

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions