fix(health): measure list-typed data keys with LLEN, not STRLEN#5254
Conversation
/api/health STRLENs every registered data key. `forecast:bets:history:v1` is a Redis LIST (LPUSH/LTRIM in scripts/seed-forecast-bets.mjs), so STRLEN returns WRONGTYPE, the pipeline records a per-command error, and classifyKey pins the key at REDIS_PARTIAL (records: null) — permanently, for a seeder that is perfectly healthy. Prod has reported `forecastBets: REDIS_PARTIAL` ever since #5233 shipped the key, and it was one of the two warns holding /api/health at DEGRADED. Verified against prod Redis: STRLEN forecast:bets:history:v1 -> WRONGTYPE ... -> REDIS_PARTIAL, records null LLEN forecast:bets:history:v1 -> 1 -> OK, records 4 LIST_DATA_KEYS names the list-typed keys; dataLenCommand() picks LLEN for them and STRLEN for everything else, and keyHasData() gates presence on `len > 0` for lists. That last part is load-bearing: strlenIsData() rejects exactly 10 bytes as the '__WM_NEG__' sentinel, which is a STRING concept — reusing it for LLEN would silently classify a 10-ELEMENT history as EMPTY (crit). A TYPE sweep of all 218 registered data keys against prod confirms forecastBets is the only non-string key today. tests/health-list-data-keys.test.mjs pins both halves (LLEN is issued; a 10-element list is data) plus the regressions: a real per-command error still surfaces REDIS_PARTIAL, and string keys keep NEG_SENTINEL semantics.
Strix Security ReviewNo security issues found. Updated for Reviewed by Strix |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR fixes a production
Confidence Score: 5/5Safe to merge. The change is narrowly scoped to the Redis measurement command for list-typed keys and has no effect on string keys or any other part of the health pipeline. The fix is surgical: one new Set, two small pure functions, two call-site swaps. All three paths (list key healthy, list key erroring, string key with sentinel) are explicitly pinned by tests confirmed failing before the fix. The only observable gap is a stale comment on the keyStrens map variable. No files require special attention. api/health.js carries a minor comment inconsistency on the keyStrens map that is worth cleaning up but does not affect behaviour. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant H as handler (health.js)
participant DC as dataLenCommand()
participant R as Redis pipeline
participant CK as classifyKey()
H->>DC: allDataKeys.map(dataLenCommand)
DC-->>H: LLEN for list keys / STRLEN for string keys
H->>R: pipeline([LLEN/STRLEN, GET meta, EXISTS markers])
R-->>H: results (numeric len or error per key)
H->>H: populate keyStrens / keyErrors maps
H->>CK: classifyKey(name, redisKey, opts, ctx)
CK->>CK: "keyHasData: list->len>0, string->strlenIsData"
CK-->>H: "{status, records, ...}"
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant H as handler (health.js)
participant DC as dataLenCommand()
participant R as Redis pipeline
participant CK as classifyKey()
H->>DC: allDataKeys.map(dataLenCommand)
DC-->>H: LLEN for list keys / STRLEN for string keys
H->>R: pipeline([LLEN/STRLEN, GET meta, EXISTS markers])
R-->>H: results (numeric len or error per key)
H->>H: populate keyStrens / keyErrors maps
H->>CK: classifyKey(name, redisKey, opts, ctx)
CK->>CK: "keyHasData: list->len>0, string->strlenIsData"
CK-->>H: "{status, records, ...}"
|
The bug
/api/healthmeasures every registered data key withSTRLEN. Butforecast:bets:history:v1is a Redis list —seed-forecast-bets.mjs:161-163writes it withLPUSH/LTRIM.STRLENagainst a list returnsWRONGTYPE, the pipeline records that as a per-command error, andclassifyKeyturns it intoREDIS_PARTIAL(records: null) — permanently, for a seeder that is perfectly healthy.Prod has reported
forecastBets: REDIS_PARTIALever since #5233 registered the key, and it is one of the two warns currently holding/api/healthatDEGRADED.Verified against prod Redis
The seeder was never broken:
seed-meta:forecast:betsis fresh withrecordCount: 4.The fix
LIST_DATA_KEYSnames the list-typed keys,dataLenCommand()issuesLLENfor them andSTRLENfor everything else, andkeyHasData()gates presence onlen > 0for lists.That last part is load-bearing.
strlenIsData()rejects exactly 10 bytes as the__WM_NEG__sentinel — a string concept. Reusing it forLLENwould silently classify a 10-element history asEMPTY(crit). The test pins that case explicitly.A
TYPEsweep of all 218 registered data keys against prod Redis confirmsforecastBetsis the only non-string key today, so the blast radius is exactly one key.Tests
tests/health-list-data-keys.test.mjs— written first, confirmed failing against unfixed code (dataLenCommand is not a function; the 10-element list scoredrecords: 0instead of7). Covers both halves of the bug plus the regressions:LLENis issued for list keys,STRLENfor string keysOK, notREDIS_PARTIALREDIS_PARTIALNEG_SENTINELsemantics (strlen 10 is not data)172 tests pass across every file that touches health internals (
health-classify,health-content-age,health-redis-down-status,forecast-health-registration,health-compact-cdn-cache,bootstrap,mcp-bootstrap-parity,resilience-scores-seed,temporal-anomalies-cache).biome lintandtypecheck:apiclean.Not in scope
Found while triaging the same
DEGRADEDresponse — filing separately:acledIntel(crit) —seed-conflict-intelhas no ACLED credentials (VITE_ACLED_ACCESS_TOKENis set but empty; the seeder readsACLED_EMAIL/ACLED_PASSWORDorACLED_ACCESS_TOKEN), and its GDELT fallback is dead upstream. It publishes nothing, exits 0, and its keys have expired. Needs credentials, not code.defensePatents(crit) — the seeder targetssearch.patentsview.org, which is now NXDOMAIN; PatentsView has migrated to USPTO's Open Data Portal (needs an API key + a rewrite). The cron is already disabled; health still scores the key as crit.https://claude.ai/code/session_014xoeVpgK3YJtUcr4t5VzF4