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)
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()
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)
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)
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)
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"
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
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)