Bound TTL cleanup query for idempotency cache (op-infra Step 12 follow-up)

Context

The hourly TTL cleanup task in crates/canopy-api/src/idempotency.rs issues:

```sql DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours' ```

This is unbounded — under sustained POST traffic plus a long enough cleanup-task outage (or a run that lags hours behind), a single tick could attempt to delete tens of thousands of rows in one statement. With idempotency_keys.cache_key as the primary key, the lock window is small per row, but a giant single-statement delete still holds row-level locks and an ACCESS SHARE on the table for the duration.

Acceptance criteria

  1. Replace the unbounded DELETE with a bounded loop:

    ```sql DELETE FROM idempotency_keys WHERE cache_key IN ( SELECT cache_key FROM idempotency_keys WHERE created_at < now() - interval '24 hours' LIMIT 1000 ) ```

    Loop until rows_affected < 1000, with a brief tokio::time::sleep between iterations to yield to the executor.

  2. Log the total rows deleted per tick at DEBUG (a single tick can now span multiple statements, so the existing if rows_affected > 0 debug log becomes a running total).

  3. Unit test the bounded loop logic with a fixture pool (or doc-comment the loop bound).

Out of scope

  • Time-based partitioning of idempotency_keys (overkill for the volume we expect — POST throughput is human-scale).
  • Switching to a separate vacuum strategy (autovacuum + the bounded delete is enough).

Source

Op-infra plan Step 12, code review of the implementation MR — operational hardening that wasn't called out in the original plan but is good practice for any periodic batch delete.