koala73/worldmonitor · error
cache_all_null
Error message
cache_all_null
What it means
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.
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
Defensive patterns
Strategy: retry
Validate before calling
// Optional pre-flight: confirm the tool's cache keys exist before paying for the call
// (health endpoint reports dataset freshness; seed-meta:<key> markers prove a seed ran)
const health = await fetch('https://<host>/api/health').then(r => r.json());
const datasetFresh = health?.datasets?.[datasetKey]?.fresh === true;
if (!datasetFresh) skipOrDefer('dataset not seeded'); Type guard
function isCacheAllNull(e) {
return e instanceof Error && e.message === 'cache_all_null';
} Try / catch
async function callCacheTool(name, args, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try { return await client.callTool(name, args); }
catch (e) {
if (isCacheAllNull(e) && i < attempts - 1) {
await sleep(500 * 2 ** i + Math.random() * 250); // transient Redis/stampede window
continue;
}
throw e;
}
}
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
AI-assisted analysis of koala73/worldmonitor@e586b8b4b8 (2026-08-21).
Data as JSON: /api/errors/c1165ee7473ad73f.
Report an issue: GitHub.
Appendix: source
Thrown at api/mcp/dispatch.ts:139
metas,
evaluatedAt,
activationStates,
);
// F6: if every cache key returned null/undefined AND the tool actually
// had keys configured, this is a degenerate-empty result (Redis transient
// / stampede). Throw so dispatchToolsCall reports a normal tool-execution
// failure; for Pro callers the already-reserved daily slot stays charged
// because this check runs after the tool has executed.
//
// Cache-tools always have at least one key (validated in the registry
// type). The all-null case is structurally distinguishable from "the
// upstream returned an empty list" (which is a JSON value, not null).
if (
tool._cacheKeys.length > 0 &&
results.every((v: unknown) => v === null || v === undefined)
) {
throw new Error('cache_all_null');
}
const data: Record<string, unknown> = {};
// Walk backward through ':'-delimited segments, skipping non-informative suffixes
// (version tags, bare numbers, internal format names) to produce a readable label.
const NON_LABEL = /^(v\d+|\d+|stale|sebuf)$/;
tool._cacheKeys.forEach((key, i) => {
const parts = key.split(':');
let label = '';
for (let idx = parts.length - 1; idx >= 0; idx--) {
const seg = parts[idx] ?? '';
if (!NON_LABEL.test(seg)) { label = seg; break; }
}
data[tool._cacheLabels?.[key] || label || (parts[0] ?? key)] = results[i];
});
// Optional in-memory post-filter (declared per-tool, mirrors that tool's
// inputSchema.properties). A filter bug must NEVER break the tool — on throwView on GitHub (pinned to e586b8b4b8)