Archiving Expired Idempotency Keys for Audit

A 24-hour idempotency window is right for replay and wrong for evidence. Once a key expires, the system forgets an operation ever happened — so a client retrying 30 hours later executes a second charge, and an incident investigation three days later cannot reconstruct which requests were in flight. A slim archive fixes both without inflating the hot store. This runbook sits under idempotency key storage & TTL management.

Prerequisites. A live key store with a defined TTL — see choosing TTL values for idempotency keys — and a relational or columnar store for the archive.


Step 1 — Design the archive row

Live record versus archive row Two stacked record diagrams. The live record holds the scoped key, fingerprint, state, status code, full response body and expiry, averaging four hundred bytes and retained for twenty-four hours. The archive row holds only the scoped key, fingerprint, status code, resource identifier and timestamps, averaging one hundred and ten bytes and retained for ninety days. An arrow between them is labelled sweeper, dropping the response body. Live record — hot store, 24 h scoped_key fingerprint state status response body (up to 64 KB) expires_at ≈ 400 bytes average · 173 M live rows at 2,000 rps · ~69 GB sweeper on expiry drops the body, keeps the evidence Archive row — cold store, 90 days scoped_key fingerprint status resource_id created / expired at no body no personal data ≈ 110 bytes average · 90 days at 2,000 rps · ~1.7 GB compressed 40× smaller per row, and outside the scope of erasure requests. The sweeper loop A cycle of four steps: select up to ten thousand expired rows with for-update skip-locked, insert the slim archive rows, delete the live rows, then pause two hundred milliseconds before repeating. Skip-locked claiming means several sweeper instances never block one another, and the bounded batch keeps every lock short. SELECT … LIMIT 10000 FOR UPDATE SKIP LOCKED INSERT archive metadata only, no body DELETE live rows same transaction sleep 200 ms yield to traffic, repeat An unbounded DELETE on this table locks it for minutes and stalls every registration.
CREATE TABLE idempotency_key_archive (
    scoped_key   text        NOT NULL,
    fingerprint  bytea       NOT NULL,      -- SHA-256, not the body
    status_code  smallint    NOT NULL,
    resource_id  text,                      -- e.g. the charge id, for reconciliation
    created_at   timestamptz NOT NULL,
    expired_at   timestamptz NOT NULL,
    PRIMARY KEY (scoped_key, expired_at)
) PARTITION BY RANGE (expired_at);

CREATE TABLE idempotency_key_archive_2026_08 PARTITION OF idempotency_key_archive
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

No response column. That single omission is what keeps the archive small, cheap, and free of personal data — which in turn keeps it out of scope for erasure requests and lets you retain it for years without a compliance conversation.


Step 2 — Archive on expiry, in batches

-- Move expired live rows into the archive and delete them, in one transaction,
-- bounded so the lock is never held long.
WITH expired AS (
    SELECT scoped_key, fingerprint, status_code, response->>'id' AS resource_id,
           created_at, expires_at
      FROM idempotency_keys
     WHERE expires_at < now()
     ORDER BY expires_at
     LIMIT 10000
     FOR UPDATE SKIP LOCKED             -- concurrent sweepers never block each other
), archived AS (
    INSERT INTO idempotency_key_archive
        (scoped_key, fingerprint, status_code, resource_id, created_at, expired_at)
    SELECT scoped_key, fingerprint, status_code, resource_id, created_at, expires_at
      FROM expired
    ON CONFLICT (scoped_key, expired_at) DO NOTHING
    RETURNING scoped_key
)
DELETE FROM idempotency_keys k
 USING archived a
 WHERE k.scoped_key = a.scoped_key;
def sweep(conn, batch=10_000, pause_s=0.2) -> int:
    """Run until nothing is left to archive. Bounded batches keep locks short."""
    total = 0
    while True:
        with conn.transaction():
            moved = conn.execute(ARCHIVE_SQL, (batch,)).rowcount
        total += moved
        metrics.inc("idempotency_archived_total", moved)
        if moved == 0:
            return total
        time.sleep(pause_s)             # yield to request traffic

For a Redis-backed live store the sweeper cannot see expirations directly. Subscribe to keyspace notifications, or — more reliably — write the archive row at completion time rather than at expiry, since the record is immutable from then on.

# Redis variant: archive at completion, not at expiry. Keyspace notifications are
# best-effort and are dropped when a client is slow, so they cannot be the only path.
def complete(scoped, status, body, resource_id):
    redis.hset(scoped, mapping={"state": "completed", "status": status, "body": body})
    redis.pexpire(scoped, TTL_MS)
    archive.insert(scoped_key=scoped, fingerprint=fp, status_code=status,
                   resource_id=resource_id, created_at=created, expired_at=now + TTL)

Step 3 — Answer a late retry correctly

The archive turns an ambiguous situation into a definite one.

def handle(scoped, fingerprint):
    live = store.register_or_get(scoped, fingerprint)
    if live is None:
        # Nothing live — but did this key ever exist?
        past = archive.lookup(scoped)
        if past is not None:
            store.release(scoped)                 # undo the speculative registration
            raise ApiError(409, "idempotency_key_expired", detail={
                "original_status": past.status_code,
                "resource_id": past.resource_id,   # enough to reconcile
                "created_at": past.created_at.isoformat(),
            })
        return "execute"
    ...

Returning the resource_id matters. Without it, the client knows only that it must not retry; with it, the client can fetch the original resource and reconcile without a support ticket.

The archive lookup only runs on the fresh-key path, which is the overwhelming majority of requests — so it must be fast. Keep it to a point lookup on the partitioned primary key, and skip it entirely for keys whose embedded timestamp (in a ULID or UUIDv7) is newer than the live TTL:

def needs_archive_lookup(key: str) -> bool:
    ts = ulid_timestamp_ms(key)
    return ts is not None and (time.time() * 1000 - ts) > LIVE_TTL_MS

Step 4 — Partition and drop

-- Monthly partitions: retention becomes a metadata operation, not a mass delete.
CREATE TABLE idempotency_key_archive_2026_09 PARTITION OF idempotency_key_archive
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

-- Retire the month that has aged out. Instant, no vacuum, no bloat.
DROP TABLE idempotency_key_archive_2026_05;
# Create next month's partition ahead of time; a missing partition rejects inserts.
psql -c "SELECT format(
  'CREATE TABLE IF NOT EXISTS idempotency_key_archive_%s PARTITION OF
   idempotency_key_archive FOR VALUES FROM (%L) TO (%L)',
  to_char(d, 'YYYY_MM'), d, d + interval '1 month')
  FROM generate_series(date_trunc('month', now()),
                       date_trunc('month', now()) + interval '2 months',
                       interval '1 month') d;" -tA | psql

Run partition creation on a schedule with at least two months of lead time. A missing partition does not degrade gracefully — inserts fail outright, and the sweeper stops archiving.


Verification and testing

Confirm the archive is written and the live row removed.

SELECT count(*) FROM idempotency_keys        WHERE expires_at < now();  -- expect 0
SELECT count(*) FROM idempotency_key_archive WHERE expired_at > now() - interval '1 day';

Confirm a late retry gets 409, not a second execution.

psql -c "INSERT INTO idempotency_key_archive
         (scoped_key, fingerprint, status_code, resource_id, created_at, expired_at)
         VALUES ('idem:v1:probe', '\\x00', 201, 'ch_probe', now() - interval '30 hours',
                 now() - interval '6 hours')"
curl -s -XPOST localhost:8080/v1/charges -H 'Idempotency-Key: probe' \
  -H 'Content-Type: application/json' -d '{"amount":4500,"currency":"gbp"}' | jq -r '.code'
# expect: idempotency_key_expired

Confirm no personal data leaked in.

SELECT column_name, data_type FROM information_schema.columns
 WHERE table_name = 'idempotency_key_archive';
-- expect: no jsonb/text column capable of holding a request or response payload

Confirm partitions exist ahead of time.

SELECT relname FROM pg_class c JOIN pg_inherits i ON c.oid = i.inhrelid
 WHERE i.inhparent = 'idempotency_key_archive'::regclass ORDER BY relname DESC LIMIT 3;
-- expect: at least the next two months

Failure scenarios and debugging

Failure Scenario Remediation Steps Observability Hooks
Sweeper falls behind, live table grows unbounded, and vacuum starts stalling the write path Increase batch size or run more sweeper instances — FOR UPDATE SKIP LOCKED makes them safely concurrent. Alert on the backlog rather than on table size. idempotency_expired_backlog gauge (count(*) WHERE expires_at < now()); alert above 100,000
Next month’s partition missing, so archiving fails silently and expired rows accumulate Create partitions two months ahead on a schedule, and alert if the newest partition’s upper bound is under 30 days away. idempotency_archive_newest_partition_days gauge; page below 30
Response body copied into the archive “just in case”, putting customer data under a 7-year retention Drop the column. If a body is genuinely needed for audit, store a pointer to the existing record in the primary datastore instead of a second copy. A schema-diff check in CI rejecting any new column on the archive table
Archive lookup added to the hot path for every fresh key, adding a round trip to 98% of requests Gate the lookup on the key’s embedded timestamp, or on a Bloom filter of archived keys refreshed hourly. idempotency_archive_lookup_total divided by idempotency_registration_total; should be well under 0.05
Redis-backed store relies on keyspace notifications to archive, and events are dropped under load Archive at completion time instead. Keyspace notifications are fire-and-forget and are not a durable event source. Archive row count versus completed-key count per hour; a persistent shortfall proves loss
Keeping the archive off the hot path A fresh key arrives. Its embedded timestamp is compared against the live retention window. If the key was minted within the window, no archive query is issued at all, which covers the overwhelming majority of requests. Only a key older than the window triggers a point lookup in the archive, which then answers with 409 expired. fresh key not in the live store key timestamp older than the window? no → execute, zero extra queries (≈98% of traffic) yes → archive point lookup hit → 409 expired + resource_id to reconcile

SRE / observability checklist

  1. idempotency_expired_backlog — Gauge of live rows past expiry. Alert above 100,000; it is the leading indicator of vacuum trouble.
  2. idempotency_archived_total — Counter of rows moved per sweep. A flat line while the backlog grows means the sweeper is wedged.
  3. idempotency_archive_newest_partition_days — Gauge of days until the newest partition’s upper bound. Page below 30.
  4. idempotency_expired_replay_total — Counter of 409 idempotency_key_expired responses. Non-zero on a payments route means clients are retrying past the window and the archive is earning its keep.
  5. idempotency_archive_lookup_duration_ms — Histogram of the archive point lookup. Alert if p99 exceeds 5 ms; it sits on the fresh-key path.
  6. idempotency_archive_bytes — Gauge from pg_total_relation_size. Growth well above the modelled 110 bytes per row means a column crept in.