{"record":{"id":"c1165ee7473ad73f","repo":"koala73/worldmonitor","slug":"cache-all-null","errorCode":null,"errorMessage":"cache_all_null","messagePattern":"cache_all_null","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"api/mcp/dispatch.ts","lineNumber":139,"sourceCode":"    metas,\n    evaluatedAt,\n    activationStates,\n  );\n\n  // F6: if every cache key returned null/undefined AND the tool actually\n  // had keys configured, this is a degenerate-empty result (Redis transient\n  // / stampede). Throw so dispatchToolsCall reports a normal tool-execution\n  // failure; for Pro callers the already-reserved daily slot stays charged\n  // because this check runs after the tool has executed.\n  //\n  // Cache-tools always have at least one key (validated in the registry\n  // type). The all-null case is structurally distinguishable from \"the\n  // upstream returned an empty list\" (which is a JSON value, not null).\n  if (\n    tool._cacheKeys.length > 0 &&\n    results.every((v: unknown) => v === null || v === undefined)\n  ) {\n    throw new Error('cache_all_null');\n  }\n\n  const data: Record<string, unknown> = {};\n  // Walk backward through ':'-delimited segments, skipping non-informative suffixes\n  // (version tags, bare numbers, internal format names) to produce a readable label.\n  const NON_LABEL = /^(v\\d+|\\d+|stale|sebuf)$/;\n  tool._cacheKeys.forEach((key, i) => {\n    const parts = key.split(':');\n    let label = '';\n    for (let idx = parts.length - 1; idx >= 0; idx--) {\n      const seg = parts[idx] ?? '';\n      if (!NON_LABEL.test(seg)) { label = seg; break; }\n    }\n    data[tool._cacheLabels?.[key] || label || (parts[0] ?? key)] = results[i];\n  });\n\n  // Optional in-memory post-filter (declared per-tool, mirrors that tool's\n  // inputSchema.properties). A filter bug must NEVER break the tool — on throw","sourceCodeStart":121,"sourceCodeEnd":157,"githubUrl":"https://github.com/koala73/worldmonitor/blob/e586b8b4b80f595aa7ece295eec10d76f2921240/api/mcp/dispatch.ts#L121-L157","documentation":"Thrown by executeTool in api/mcp/dispatch.ts when a cache-backed MCP tool has at least one _cacheKeys entry and EVERY Redis read for those keys returned null/undefined. This is the F6 contract: an all-null read set is a degenerate-empty result (Redis transient failure, cache stampede, or keys never seeded), not a legitimate empty dataset — a genuine empty list is a JSON value, not null. It is deliberately checked after tool execution, so for Pro callers the already-reserved daily slot stays charged; dispatchToolsCall then reports it as a normal tool-execution failure (-32603 class), not a quota refund.","triggerScenarios":"Calling any cache-backed tools/call (tools declared with _cacheKeys in the registry) while: the Upstash Redis REST endpoint is degraded/unreachable and reads resolve null; a cache stampede or eviction wiped all the tool's keys mid-request; the deployment never ran the seed scripts so the keys do not exist yet.","commonSituations":"Fresh environment/worktree where seed scripts were not run (missing seed-meta:<key> markers). Upstash outage or rate limiting returning nulls. TTL expiry of all of a tool's keys between producer runs. Region/env mismatch — MCP edge pointing at a different Upstash database than the one the producers write.","solutions":["Retry the same tools/call after a short delay — transient Redis nulls and stampede windows usually clear in seconds","Check the health surfaces (/api/health, /api/seed-health) for the dataset's key freshness and seed-meta markers to confirm the keys exist at all","If keys are genuinely absent, run the corresponding seed script (scripts/ follow seed metadata rules; they must write seed-meta:<key>)","Verify UPSTASH_REDIS_REST_URL/TOKEN on the MCP edge point at the same store the producers write to"],"exampleFix":null,"handlingStrategy":"retry","validationCode":"// Optional pre-flight: confirm the tool's cache keys exist before paying for the call\n// (health endpoint reports dataset freshness; seed-meta:<key> markers prove a seed ran)\nconst health = await fetch('https://<host>/api/health').then(r => r.json());\nconst datasetFresh = health?.datasets?.[datasetKey]?.fresh === true;\nif (!datasetFresh) skipOrDefer('dataset not seeded');","typeGuard":"function isCacheAllNull(e) {\n  return e instanceof Error && e.message === 'cache_all_null';\n}","tryCatchPattern":"async function callCacheTool(name, args, attempts = 3) {\n  for (let i = 0; i < attempts; i++) {\n    try { return await client.callTool(name, args); }\n    catch (e) {\n      if (isCacheAllNull(e) && i < attempts - 1) {\n        await sleep(500 * 2 ** i + Math.random() * 250); // transient Redis/stampede window\n        continue;\n      }\n      throw e;\n    }\n  }\n}","preventionTips":["Monitor /api/health and seed-meta markers for the datasets your workflow depends on before batching MCP calls","Budget for the charge semantics: the failed call still consumed the Pro slot, so retry sparingly and back off","Treat repeated cache_all_null across many tools as an Upstash/env problem, not per-tool bad luck — check UPSTASH_REDIS_REST_URL first"],"tags":["mcp","redis","upstash","cache","dispatch"],"backgroundTag":"redis-cache-empty","analyzedSha":"e586b8b4b80f595aa7ece295eec10d76f2921240","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}