DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Observed Cache-Key Strings Before One Hasher Extract

Cache-key extracts fail without pinned observed key strings. Missing fields and explicit None often collide after cleanup. Record the live key bytes first, then extract one hasher.

Messy service modules inline cache keys beside I/O. Coding assistants then rewrite key building during refactors. Hit rates drop while duplicate writes silently appear.

The failure then hides behind otherwise green unit tests. Characterization must pin bytes, not intended cleanliness.

This workflow pins observed key strings before any extract. It then moves exactly one small hasher function. It does not rewrite the whole messy module.

Why observed keys rot during refactors

Inline key builders mix dict order with hidden types. Python 3.7+ preserves insertion order by language rule. Insertion order is still not a public cache contract.

json.dumps without sort_keys follows that insertion order. Equal payloads can therefore emit different key strings.

None and a missing key are not equal values. json.dumps emits null for explicit None fields. A missing key disappears from the serialized object.

Assistants often normalize both cases into None. That merge then changes every downstream cache identity.

Lists remain order-sensitive in most product identities. Sorting tags for stability quietly breaks those identities. Datetime values raise TypeError inside default json.dumps.

Hidden default=str helpers leak timezone-shaped key text. Those leaks become the real on-the-wire cache key.

A prettier canonical JSON dump is a different product change. Do not smuggle it into the hasher extract. Pin the live string first, then decide on canonicalization.

Artifact: a two-column key pin

Treat the harness below as a proposed local pin. Do not treat these fixtures as production telemetry. The extract must preserve the live output column.

Record a diagnostic canonical digest beside live keys. Never replace live keys with that diagnostic digest.

1. Alias the live builder

Do not extract a helper during this step. Find the inline builder in the messy module. Route tests through one thin alias only.

# proposed harness alias; keep the messy implementation in place
from billing_service import _inline_cache_key  # existing code

def build_cache_key(payload: dict) -> str:
    return _inline_cache_key(payload)
Enter fullscreen mode Exit fullscreen mode

Call the real path from every case. Reimplementation here would pin fiction, not behavior.

2. Add a diagnostic canonical dump

The diagnostic dump exists for diffs, not for serving traffic. It makes None-versus-missing visible in review. It must not become the new cache key in the same commit.

import hashlib
import json
from datetime import datetime, timezone


def canonical_dumps(obj) -> str:
    def default(value):
        if isinstance(value, datetime):
            if value.tzinfo is None:
                value = value.replace(tzinfo=timezone.utc)
            return value.astimezone(timezone.utc).strftime(
                "%Y-%m-%dT%H:%M:%SZ"
            )
        raise TypeError(f"unpinned type: {type(value).__name__}")

    return json.dumps(
        obj,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=True,
        default=default,
    )


def diagnostic_digest(obj) -> str:
    blob = canonical_dumps(obj).encode("utf-8")
    return hashlib.sha256(blob).hexdigest()
Enter fullscreen mode Exit fullscreen mode

sort_keys=True applies to dict keys only. Lists keep order under this contract. That split is the whole diagnostic value.

3. Record a case table with two columns

Name every case in a stable dictionary. Store live output and diagnostic JSON together. Fail when either column drifts after the extract.

import json
import os
from datetime import datetime, timezone
from pathlib import Path

GOLDEN = Path("goldens/cache_key_cases.json")
RECORD = os.environ.get("RECORD_GOLDENS") == "1"

CASES = {
    "missing_user": {"order_id": 9},
    "null_user": {"order_id": 9, "user_id": None},
    "reordered": {"user_id": 3, "order_id": 9},
    "list_ab": {"tags": ["a", "b"]},
    "list_ba": {"tags": ["b", "a"]},
    "nested": {"meta": {"b": 1, "a": 2}},
    "aware_dt": {
        "ts": datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc)
    },
    "naive_dt": {"ts": datetime(2026, 9, 20, 12, 0)},
    "bool_not_int": {"ok": True},
    "int_not_bool": {"ok": 1},
}


def rows_for(current_key_fn):
    rows = {}
    for name, payload in CASES.items():
        live = current_key_fn(payload)
        if not isinstance(live, str):
            raise TypeError(f"{name} emitted {type(live).__name__}")
        rows[name] = {
            "live": live,
            "canonical_json": canonical_dumps(payload),
            "diagnostic_sha256": diagnostic_digest(payload),
        }
    return rows


def record_or_check(current_key_fn):
    rows = rows_for(current_key_fn)
    if RECORD:
        GOLDEN.parent.mkdir(parents=True, exist_ok=True)
        GOLDEN.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
        return
    if not GOLDEN.exists():
        raise FileNotFoundError("set RECORD_GOLDENS=1 once, then commit")
    expected = json.loads(GOLDEN.read_text())
    assert rows == expected, (rows, expected)
Enter fullscreen mode Exit fullscreen mode

The record path is explicit and opt-in. A first-run side effect is too easy to miss. Commit the JSON before any hasher extract.

4. Lock the dangerous inequalities

Keep four direct asserts beside the table. Tables hide intent when they only store blobs. These asserts name the collisions assistants usually create.

def test_none_is_not_missing():
    a = diagnostic_digest({"order_id": 9})
    b = diagnostic_digest({"order_id": 9, "user_id": None})
    assert a != b


def test_dict_order_does_not_matter_in_diagnostic():
    a = diagnostic_digest({"user_id": 3, "order_id": 9})
    b = diagnostic_digest({"order_id": 9, "user_id": 3})
    assert a == b


def test_list_order_does_matter():
    a = diagnostic_digest({"tags": ["a", "b"]})
    b = diagnostic_digest({"tags": ["b", "a"]})
    assert a != b


def test_true_is_not_one():
    assert diagnostic_digest({"ok": True}) != diagnostic_digest({"ok": 1})


def test_live_builder_is_pinned():
    record_or_check(build_cache_key)
Enter fullscreen mode Exit fullscreen mode

The live builder may still depend on insertion order. That fact belongs in the live column. Do not "fix" it during the extract commit.

5. Extract one hasher only

After goldens pass without RECORD_GOLDENS, move one function. Keep the alias name stable for callers. Do not touch I/O, TTL, or key prefixes.

def extract_cache_key(payload: dict) -> str:
    # proposed extract: body copied from _inline_cache_key
    return _inline_cache_key(payload)


def build_cache_key(payload: dict) -> str:
    return extract_cache_key(payload)
Enter fullscreen mode Exit fullscreen mode

Re-run the pin with recording disabled. If the live column changes, stop immediately. Revert the extract and inspect the JSON diff. Do not edit goldens to match a cleaner hasher.

Switching traffic to diagnostic_digest is a second change. It needs a versioned prefix and a TTL plan. It is not part of this extract.

Commands that keep the pin honest

Record once on an unchanged tree. Then assert on a clean second run.

mkdir -p goldens tests
RECORD_GOLDENS=1 python -m pytest tests/test_cache_key_goldens.py -q
python -m pytest tests/test_cache_key_goldens.py -q
git add goldens/cache_key_cases.json tests/test_cache_key_goldens.py
git commit -m "pin observed cache-key strings before hasher extract"
Enter fullscreen mode Exit fullscreen mode

After the extract, diff only the golden file.

python -m pytest tests/test_cache_key_goldens.py -q
git diff -- goldens/cache_key_cases.json
Enter fullscreen mode Exit fullscreen mode

A one-line JSON change is a behavior change. Treat that line as a product decision, not noise.

Decision table: what may share a commit

Use this table before accepting a model patch. If a row says no, split the work.

Change Pin first Same commit as extract
Move hasher body to one function Yes Yes
Preserve live key strings Yes Yes
Add sort_keys to live keys Yes No
Collapse missing keys into None Yes No
Sort list values for stability Yes No
Add default=str for datetimes Yes No
Change key prefix or TTL Yes No
Adopt diagnostic SHA-256 as the key Yes No

The extract commit has one job. It relocates bytes without changing bytes. Every other row is a keyed migration.

Three patches that usually break goldens

Patch A inserts payload.setdefault("user_id", None). Missing-user and null-user then collide. The diagnostic digest column catches that merge.

Patch B sorts tag lists before hashing. list_ab and list_ba then match. Product identity for ordered tags is lost.

Patch C formats datetimes with str(ts). Naive and aware clocks then depend on the host. The live column drifts across machines.

Reject all three during the extract. File them as separate migrations with prefixes. Do not bargain with the golden file.

Re-running the pin off your laptop

Naive datetime paths still leak host timezone. Default encodings still leak host locale. A second machine is useful after goldens exist.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option. Use the free server to replay the same pytest pin, not to invent a prettier key.

After the goldens pass, a free model can draft the one-function extract. You still reject any patch that retouches goldens. Redact user ids from fixtures before any prompt.

Do not paste production payloads into a remote prompt. Cache keys often embed account identifiers. Keep those pins local and stripped.

Limitations

JSON diagnostic dumps are not RFC 8785 JCS. Do not use diagnostic_digest for signatures or tokens. ensure_ascii=True escapes non-ASCII bytes on purpose. Changing that flag rotates every diagnostic row.

Floats remain an open hole in this pin. 1.0 and 1 differ in JSON output. NaN is not legal JSON and must raise. Decimal needs an explicit encoder before it enters cases.

This workflow assumes a single-process Python service. It does not rewrite Redis contents already stored. Old live keys remain until TTL expiry. Plan a v2: prefix if canonicalization must ship later.

default=str stays banned inside the diagnostic dump. str(datetime) is not a stable contract here. The explicit UTC format is the diagnostic point.

The live column may already be unstable across processes. If it is, the pin documents that instability. Stability is a later change, not this extract.

Who should skip this

Do not use this if keys already come from protobuf or msgpack. Do not use this for cryptographic signing material. Do not use this when list order is free and writers already sort.

Do not extract a hasher while changing TTL in one commit. Do not extract a hasher while changing codec in one commit. Do not extract a hasher while adding a prefix in one commit.

Skip the remote re-run when fixtures still contain production payloads. Keep those pins on a redacted local tree. The method is for behavior locks, not for data export.

Teams with an existing canonical codec should pin that codec instead. Do not introduce a second JSON flavor beside it. Two canonicalizers will drift under review.

Close

Pin observed key strings before the hasher extract. Keep None distinct from missing fields. Keep list order unless product code already sorts.

Force UTC only in the diagnostic dump. Move one function and leave goldens frozen. If the JSON file moves, the refactor is no longer small.

Publish the case table with the extract review. Leave model chat transcripts out of the merge.

Top comments (0)