perf: index the link-cache prune, evict chats with no live prompt

Two things the 300 s sweep did the hard way:

- The link cache is pruned by `created_at` (`DELETE FROM link_cache WHERE
  created_at < ?`) and had no index on it, so every sweep scanned the whole
  table — every post sent inside the TTL window, which is up to a week of
  them — while the `url` primary key served none of it. A new migration
  (appended; migration 1 is frozen and already shipped) creates the index, and
  the upgrade test now asserts it exists after an upgrade.
- `ChatStore::prune_expired` only ever *looked* at chats that had an expired
  edit-before-forward record, so a chat with no prompt at all — the common
  case: every chat that ever sent a message or ran a command — stayed in the
  cache and in the per-chat lock map for the process lifetime. The candidate
  set now includes chats holding no records, which is what the eviction below
  was written for; the DB keeps the row, so the next use costs one SELECT
  (pinned by a new test that also shows the durable settings come back).

Deliberately *not* done: skipping the write in `ChatStore::set` when the state
is unchanged. Comparing against the cached copy would skip a serialize plus a
blocking DB round trip for a no-op update — but every one of the 13 `update`
callers mutates something, so the no-op case is a user repeating an identical
command, and the same comparison would also skip the write that repairs a row
whose earlier write failed. A rare saving against a rare repair, and the write
is what makes the cache a cache rather than a source of truth.

Verified: the new eviction test fails without the candidate change (checked by
reverting it) and passes with it; 118 bot tests and 91 x-media tests pass.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
This commit is contained in:
2026-09-21 13:52:43 +08:00
parent 670a7bd02b
commit 507c8ac317
2 changed files with 82 additions and 7 deletions
+17
View File
@@ -139,6 +139,11 @@ const MIGRATIONS: &[&str] = &[
// written by its former holder — which used to duplicate a send or drop
// the new holder's retry state, silently.
"ALTER TABLE tasks ADD COLUMN lease_token TEXT",
// 2: the 300 s sweep prunes the link cache by `created_at`
// (`DELETE FROM link_cache WHERE created_at < ?`). Without an index that
// is a full scan of every post sent inside the TTL window — up to a week
// of them — on every sweep; the `url` primary key cannot serve it.
"CREATE INDEX IF NOT EXISTS idx_link_cache_created_at ON link_cache(created_at)",
];
/// Brings an existing database up to [`MIGRATIONS`]. Idempotent: a database
@@ -285,6 +290,18 @@ mod tests {
)
.unwrap();
assert_eq!(payload, "{\"chat_id\":1}", "rows survive the upgrade");
// The link-cache prune's index arrives with the migrations (the
// baseline schema has none): without it every sweep scans the
// whole table.
let index: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'index' AND name = 'idx_link_cache_created_at'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(index, 1, "the migration's index must exist");
Ok(())
})
.await