Tuning Redis Idempotency Cache TTL for Spatial Jobs

A retried or rebalanced spatial job that finds no dedup record re-runs an expensive ST_Intersection or tile-build it has already paid for — but a dedup record that lingers forever quietly leaks memory until the Redis node evicts live keys under pressure. This operation, part of Async Execution for Heavy Spatial Queries within the Federated Ownership & Routing Architecture, sizes the TTL on the idempotency store keyed by SHA-256(bbox+crs+params) so it comfortably covers the retry and fallback windows without hoarding stale fingerprints. It shares the same x-idempotency-key convention exercised in Building Fallback Chains for Routing APIs, so a job started synchronously and a job replayed asynchronously collapse onto one key.

Figure — the idempotency record must outlive the job’s worst-case duration plus the retry and fallback windows, yet expire before it becomes dead memory.

Idempotency TTL sized against job duration, retries, and fallback window A horizontal timeline. At t0 a SET NX EX writes the SHA-256 fingerprint key. The p99 job duration spans the first segment, followed by a retry window of three exponential-backoff attempts, followed by a fallback window where a secondary tier may replay the same key. The chosen TTL is drawn as a bracket that ends safely to the right of the fallback window with headroom, then the key expires and memory is reclaimed. SET NX EX write fingerprint p99 job duration heavy join / tiling retry window 3x backoff fallback window secondary replay TTL = 3600s key expires memory reclaimed

Prerequisites

Requirement Value / Assumption Notes
Redis >= 7.0, single logical DB for the dedup namespace SET ... NX EX and OBJECT IDLETIME support
Reachability redis-idem.internal:6379, TLS in transit Same store used by the fallback chain
CLI tools redis-cli, python3 with redis-py >= 5.0 For writes, TTL checks, and INFO memory
Key schema idem:{SHA-256(bbox+crs+params)} bbox in EPSG:4326; crs and params normalized before hashing
Eviction policy maxmemory-policy volatile-ttl Evicts shortest-TTL keys first; never touches non-expiring keys
Access role spatial-worker (write dedup), mesh-routing-admin (policy) Zero-trust: workers hold write-only, no FLUSHDB
Env vars IDEMPOTENCY_TTL_SECONDS, REDIS_URL, JOB_P99_SECONDS TTL derived from the p99 duration, not guessed

Step-by-Step Implementation

Every job computes its fingerprint the same way, writes the record atomically, and lets Redis expire it. The TTL is derived, not hard-coded by taste: it is the p99 job duration plus the full retry ladder plus the fallback replay window plus a safety margin.

1. Normalize inputs and derive the fingerprint

The hash must be stable across producers, so normalize the bounding box precision, uppercase the CRS token, and sort the parameter map before hashing. An unsorted params dict silently produces two keys for one logical job.

python
import hashlib, json

def idem_key(bbox, crs, params):
    # bbox rounded to 6 dp (~0.1 m at the equator); crs and params canonical.
    norm = {
        "bbox": [round(float(c), 6) for c in bbox],   # EPSG:4326 lon/lat
        "crs": crs.upper(),                            # e.g. EPSG:4326
        "params": {k: params[k] for k in sorted(params)},
    }
    digest = hashlib.sha256(
        json.dumps(norm, separators=(",", ":"), sort_keys=True).encode()
    ).hexdigest()
    return f"idem:{digest}"

Verify two logically identical jobs collapse to one key:

bash
python3 -c "from job import idem_key; \
print(idem_key([-74.5,40.1,-73.9,40.9],'epsg:4326',{'res':'10m','op':'intersects'})); \
print(idem_key([-74.500000,40.100000,-73.900000,40.900000],'EPSG:4326',{'op':'intersects','res':'10m'}))"
# both lines must print the identical idem:<hash>

2. Derive the TTL from measured job duration

Bind the TTL to the observed p99, never a round number pulled from the air. The retry ladder here is three attempts of exponential backoff capped at 30s; the fallback window matches the secondary tier’s replay budget.

python
import os

def derive_ttl():
    p99 = int(os.environ["JOB_P99_SECONDS"])   # measured, e.g. 900
    retry_window = 2 + 4 + 8 + 16 + 30          # backoff ladder, ~60s
    fallback_window = 300                        # secondary replay budget
    safety_margin = int(0.25 * (p99 + retry_window + fallback_window))
    return p99 + retry_window + fallback_window + safety_margin

Verify the derived value matches the deployed env var:

bash
JOB_P99_SECONDS=900 python3 -c "from job import derive_ttl; print(derive_ttl())"
# compare against IDEMPOTENCY_TTL_SECONDS in the worker manifest

3. Claim the job atomically with SET NX EX

A single SET key value NX EX ttl is the whole idempotency guarantee: NX makes the write win only if no fingerprint exists, EX arms expiry in the same round trip so there is never a window where the key exists without a TTL.

python
import redis, os
r = redis.from_url(os.environ["REDIS_URL"])

def claim(key: str, trace_id: str, ttl: int) -> bool:
    # Returns True if THIS worker owns the job; False if already claimed.
    return bool(r.set(key, trace_id, nx=True, ex=ttl))

Verify a claimed key carries a bounded TTL — never -1 (no expiry):

bash
redis-cli -u "$REDIS_URL" TTL "idem:$HASH"
# expect a positive integer close to IDEMPOTENCY_TTL_SECONDS; -1 is a bug

4. Choose fixed TTL over sliding for dedup correctness

Do not refresh the TTL on read. A sliding TTL that renews on every retry can keep a hot fingerprint alive indefinitely and mask a genuinely stuck job. Fixed expiry from the moment of claim gives a deterministic dedup horizon.

python
def owns(key: str) -> bool:
    # Read-only check; deliberately does NOT reset the TTL (no sliding window).
    return r.exists(key) == 1

Verify the TTL decreases monotonically across two reads:

bash
redis-cli -u "$REDIS_URL" TTL "idem:$HASH"; sleep 5; \
redis-cli -u "$REDIS_URL" TTL "idem:$HASH"
# second value must be ~5 lower than the first

5. Bound total memory with volatile-ttl eviction

Because every dedup key has a TTL, volatile-ttl lets Redis shed the closest-to-expiry fingerprints first if it ever approaches maxmemory, protecting live claims. Confirm the used-memory ceiling holds under load.

bash
redis-cli -u "$REDIS_URL" CONFIG SET maxmemory 512mb
redis-cli -u "$REDIS_URL" CONFIG SET maxmemory-policy volatile-ttl
redis-cli -u "$REDIS_URL" INFO memory | grep -E 'used_memory_human|maxmemory_human|evicted_keys'

A steadily climbing evicted_keys under a fixed job rate means the TTL is too long for the throughput — shorten it or raise maxmemory.

Configuration Reference

Parameter Scope Value Rationale
IDEMPOTENCY_TTL_SECONDS worker env 1575 p99 900s + retry 60s + fallback 300s + 25% margin
SET ... NX claim NX Only the first writer owns the job
SET ... EX claim EX <ttl> Arms expiry atomically with the write
TTL strategy policy fixed Deterministic dedup horizon; no renew-on-read
maxmemory server 512mb Hard ceiling for the dedup namespace
maxmemory-policy server volatile-ttl Evicts nearest-expiry keys first
Key prefix schema idem: Namespaces dedup keys for SCAN and metrics
Hash input schema bbox+crs+params EPSG:4326 bbox, normalized before SHA-256

Common Failure Modes & Fixes

  • Symptom: the same heavy join runs twice under retry. Root cause: params was hashed unsorted, so a retry produced a second key. Fix: sort and canonicalize params before hashing; add the idem_key equality assertion from Step 1 to CI.
  • Symptom: redis-cli TTL returns -1. Root cause: the key was written with SET then a separate EXPIRE that never ran, or EX was dropped. Fix: always use one SET ... NX EX; audit with redis-cli TTL and alert on any -1 in the idem: namespace.
  • Symptom: evicted_keys climbs and live jobs re-run. Root cause: TTL too long for the job arrival rate, so the working set exceeds maxmemory. Fix: recompute TTL from the current p99 and raise maxmemory, or shorten the fallback window if the secondary tier no longer replays that far back.
  • Symptom: a stuck job never re-runs even after it truly failed. Root cause: a sliding TTL kept the fingerprint alive past the real job lifetime. Fix: switch to fixed TTL; never refresh on read, as shown in Step 4.
  • Symptom: dedup misses during a Redis failover. Root cause: the claim landed on a replica that had not yet received the write. Fix: issue claims against the primary with WAIT 1 100 for the critical path, and treat a missed dedup as a retry, not a duplicate materialization — the downstream ON CONFLICT write is the second line of defense.

FAQ

Why hash bbox, crs, and params instead of the whole request body?

The fingerprint must be identical for two logically equivalent jobs and different for genuinely different work. bbox+crs+params captures exactly what determines the spatial result; headers, timestamps, and trace ids would fragment the key so retries never collapse. Normalizing the bbox precision and sorting params before the SHA-256 keeps the digest stable across producers.

How do I pick the TTL number rather than guessing?

Measure the p99 job duration from your own telemetry, add the summed backoff ladder, add the secondary tier’s fallback replay window, then add a 25% safety margin. That sum is the minimum TTL that still covers every path a single job can take. Anything shorter risks a late retry finding no record and re-running the join; anything much longer just holds dead memory.

Should the TTL slide on each retry?

No. A sliding TTL renews expiry on every touch, which can keep a hot fingerprint alive indefinitely and hide a job that is actually wedged. Fixed expiry from the moment of the SET NX EX gives a deterministic dedup horizon you can reason about and alert on.

What eviction policy is safe for an idempotency store?

volatile-ttl. Because every dedup key carries a TTL, this policy evicts the keys closest to expiring first when memory is tight, which are the least valuable. Avoid allkeys-random or noeviction: the former can drop a live claim mid-job, and the latter turns memory pressure into write failures on the claim path.