Skip to content

idempotency_records grows unbounded: full response body stored per successful write, no purge job #259

Description

@mforce

Found by the #243 spec review (contrarian + architect lenses, verified against source).

The gap

IdempotencyMiddleware stores the full response body of every successful (2xx) write in idempotency_records, keyed by (AccountId, method+path, SHA256(key)). Nothing ever deletes these rows: the Jobs folder contains only DailyEntryLockSweep, DurableJobWorker", and its heartbeat — no purge reads CreatedAt. audit_events` grows the same way, but that is arguably a feature (audit retention is a product decision); cached idempotency responses are not — their usefulness ends when a client retry is no longer plausible (hours, not months).

Every production write grows the table monotonically, with response bodies inline. At farm scale this is slow-burn, but it is unbounded, and the #243 rehearsal will quantify the growth rate (its findings doc records before/after table sizes).

Suggested shape

  • A sweep job on the existing DurableJobWorker loop deleting records older than a retention window (config, default e.g. 48 h — comfortably beyond any client retry horizon, aligned with how idempotent replays are actually used by the SPA).
  • Batched deletes (the table may be large by the time this ships).
  • Integration test: an old record is purged, a fresh one survives, and a replay after purge re-executes rather than 500s.
  • Decide explicitly whether audit_events retention is in scope here or its own product decision (lean: own decision, not this issue).

Refs

Activity

  1. mforce commented on Aug 2, 2026

    @mforce
    OwnerAuthor

    Measured, not estimated — #243 harness on merged main (5df5cd7)

    The open decision was whether the purge sweep needs a CreatedAt index. Here are numbers instead of arithmetic.

    Instrument note (this part matters)

    seed --profile simulation writes zero idempotency_records rows — it invokes handlers directly through DI, with no HttpClient and no Idempotency-Key, and IdempotencyMiddleware is HTTP-layer. Confirmed empirically: a full 90-day fixture seed left the table at 0 rows. Measuring with the seeder would have produced a confident "no index needed" answer to a different question. All numbers below come from the k6 harness driving real HTTP writes.

    The run

    10 VUs (full cast: Owner/Manager/Sales/3×Worker/4×ReadOnly), 10-minute capacity phase, prod-config container.

    Capacity requests 8,480 (13.33 req/s)
    checks rate / unexpected statuses 1.0 / 0
    Per-cast-user coverage all 10, exactly once each

    Growth

    Rows written 0 → 362 in 596 s
    Write rate 0.607 rows/s (36.4/min)
    Share of traffic 362 / 8,480 = 4.3% of requests are idempotent writes
    Total relation size 376 kB (heap 200 kB + indexes 144 kB)
    Bytes per row 1,063 B

    The premise needs correcting

    This issue's framing is "full response body stored per successful write", implying bodies dominate. They don't:

    ResponseBody
    Average 68 bytes
    Max 99 bytes
    Total across 362 rows 24 kB — 6.4% of the table

    The table is dominated by row overhead, the three hash columns, and the two existing indexes — not by cached bodies. This app's write endpoints return small bodies (created ids and status), so the "unbounded response-body storage" concern is much weaker than filed. The growth concern is real; the reason stated for it is not.

    Does the sweep need an index?

    Synthesized the 48h steady state implied by the measured rate — 105,000 rows / 33 MB — with the real column shape, and ran the proposed batched delete both ways. Worst case deliberately: no row is old enough yet, so the LIMIT 1000 never short-circuits and the scan runs to completion.

    Execution time
    No index (today's schema) 16.9 ms — full Seq Scan, 105k rows
    CreatedAt btree index 0.062 ms — Index Scan
    Index cost 2,328 kB + write amplification on every idempotent write

    Recommendation: code-only sweep, no index, and do not touch InitialCreate

    The index is 273× faster in relative terms and irrelevant in absolute ones. 17 ms once per sweep interval on a background worker is free; the index would tax every write on the hot idempotency insert path to buy it back. That trade is backwards.

    Two honesty caveats on the extrapolation:

    1. 0.607 rows/s is a synthetic ceiling, not a farm. It is 10 users acting continuously with ~1.5 s think times, 24/7. Sustained naively that is ~52k rows/day → ~111 MB at a 48h window. A real farm writes in bursts during working hours, so the true steady state is orders of magnitude smaller. The unindexed scan is comfortable even at the ceiling, which is the point.
    2. Sim-stack deviations apply (per tools/simulation/README.md): plaintext local Postgres, uncapped host resources, raised rate limits. Absolute latency here is not production-equivalent — but the scan-vs-index comparison is a same-machine A/B, so the 273× ratio and the 17 ms order of magnitude hold.

    Blocker cleared along the way

    The harness could not boot merged main at all — four breakages (#319 AllowedHosts, #261/#262 TLS floor, a bootstrap.sh heredoc executing a comment, and a k6 timezone bug that failed 12.4% of requests during the hours UTC and the farm's date disagree). Fixed in a separate PR with a CI smoke so this cannot rot silently again.

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

    bugSomething isn't workingepic-1.5

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions