Caching architecture
Two independent cache layers sit in front of the paid providers, plus seven feature caches, each with its own key format, TTL and control surface.
Last updated 2026-08-06
Summary#
Metric Vault is a thin orchestration layer over expensive third-party APIs, so almost every cache in the system exists to reduce a provider bill rather than to reduce latency. There are two independent cache layers on the main tool path and seven further feature caches, all of them tables in the one D1 database bound as MONITOR_DB.
Conflating the two main layers is the single most common source of confusion, so read the distinction first: mv_seo_cache stores a whole tool response and short-circuits the handler entirely, while dfs_cache stores a raw DataForSEO endpoint response and short-circuits one provider call inside a handler that still runs. Only the second of the two is reachable from the admin console.
Purpose#
Two things are true at once, and the design has to serve both.
Provider calls cost real money. A DataForSEO endpoint reports its own price back in the response, and the LLM providers bill per token. If two customers research the same domain on the same day, paying twice for identical data is pure waste.
A customer who runs a report has run a report. The credit they spend is the price of the product, not a pass-through of our provider cost. So the cache saves our bill, never the customer's credit. The design statement is written into the code at the top of the shared-cache module: "On a cache hit the customer is still charged their normal credit (they ran a report); only our provider bill is saved."
The customer-facing explanation of what that means in practice, including the freshness a reader should expect from each tool, lives in Result caching and freshness.
Architecture#
POST /api/premium-ai
│
▼
mvSaveAroundPremiumAI ── LAYER A ──────────────────────
│ read mv_seo_cache (key: vN|<params>) │
│ HIT → quota gate → charge credit → return stored body │
│ X-MV-Cache: hit, X-MV-Cache-Age-Days: <n> │
│ MISS ↓ │
──────────────────────────────────────────────────────────
▼
handlePremiumAI (kill switch, plan gate, quota, incrementUsage)
│
▼
callDataForSEOCached(endpoint, body, env, ttlSec, tool)
│ ── LAYER B ──────────────────────
│ read dfs_cache (key: <endpoint>:<json body>) │
│ HIT → return stored provider payload │
│ MISS → callDataForSEO → INSERT OR REPLACE │
──────────────────────────────────────────────────────────
▼
LLM provider call (never cached at this layer)
│
▼
write-through to mv_seo_cache · save to tool_results · metricsEverything is D1. The KV namespace QUICKVIEW_CACHE is declared in wrangler.toml and referenced by nothing, because KV bindings on this Pages project reset on every deploy; the code comment that records that decision sits next to the quickview handler. Treat any documentation mentioning "the KV cache" as stale. See Data model.
Components#
Layer A: mv_seo_cache, the cross-customer tool-result cache#
Implemented entirely inside mvSaveAroundPremiumAI, the wrapper the router puts around POST /api/premium-ai.
| Property | Value | |
|---|---|---|
| Key | `'v1\ | ' + mvToolParamsKey(body)` |
| Key parts | type, query, country (default ww), device (default desktop), rangeDays or range, minVolume, intent; each trimmed and lowercased, joined with `\ | `, whole string capped at 300 characters |
| User identity in the key | None. That is what makes it cross-customer | |
| Stored value | The entire /api/premium-ai response body, verbatim | |
| Expiry | Read-time only: expires_at > Date.now() | |
| Admin override | None. There is no config lookup on this path |
Three preconditions must all hold before a lookup happens: the tool must appear in the MV_CACHE_TTL_MS map, the request must not carry forceFresh or _mvForceFresh, and body.query must be truthy. A tool absent from the map is never served from this cache, which makes the map the rollout control.
The MV_CACHE_TTL_MS map#
Thirty-nine tools are cached at this layer. The TTL is per tool because the underlying data changes at different speeds.
| TTL | Tools |
|---|---|
| 10 days | domain_overview, backlink_profile |
| 7 days | local_seo, audience_overlap, pla_research, influencer_analysis, keyword_overview, keyword_magic, topic_research, content_gap, ai_questions |
| 5 days | traffic_intel, organic_research, top_pages, ppc_research, advertising_research, market_explorer, ad_clarity |
| 3 days | content_decay |
| 2 days | brand_performance, social_analytics, ai_competitor_research, ai_visibility, perception, narrative_driver, prompt_research, forum_visibility |
| 1 day | site_audit, position_tracker, serp_features, keyword_gap, competitor_battle, brand_monitor, prompt_tracking, ai_overview_tracker, ai_citation_tracker, social_tracker, media_monitoring, content_analyzer |
| 12 hours | content_optimizer (the code comment reads "customers own page") |
Layer B: dfs_cache, the raw DataForSEO endpoint cache#
Every cached provider call goes through callDataForSEOCached(endpoint, body, env, ttlSec, tool).
| Property | Value |
|---|---|
| Key | endpoint + ':' + JSON.stringify(body) |
| Stored value | JSON.stringify(tasks[0].result) |
| Write | INSERT OR REPLACE |
| Timestamp | cached_at in seconds |
tool column | Optional fifth argument. NULL when the call site omits it |
TTL resolution order, implemented in mvCacheTtlSec:
platform_config['cache_days:<tool>'], if thetoolargument was passed and that key exists.platform_config['cache_days_default'].- The caller's literal
ttlSecargument. 86400(one day) whenttlSecis not a positive number.
Any error, unparseable value or value of zero or less falls through to the default. The code comment states the intent plainly: a bad config value can never break the core cache path or change data correctness, only freshness.
The remaining seven caches#
| Table | Keyed by | TTL | Notes | ||
|---|---|---|---|---|---|
dfs_hist_cache | 'hist:' + JSON body | 7 days, hardcoded | Historical rank overview. No override; swept by sweep_caches | ||
benchmarks_cache | Normalised domain | 30 days | Powers the "How you compare" panel | ||
quickview_cache | Normalised domain | 24 hours | Chrome extension. Responds with X-Cache: HIT or MISS | ||
mv_translation_cache | `sha256(src\ | tgt\ | text)` | None | Entries are permanent by design |
brief_cache | (user_email, day, target) | One UTC day | The daily brief fires on every dashboard load, so it must never charge credits or re-hit the provider | ||
gsc_cache | perf:<site>:<start>:<end>:<dimensions> | 1 hour | Cleared when the user disconnects Google |
Edge cache#
caches.default is used for four static proxy routes only, never for application data: /blog, /legal, the two logo PNGs, and nine mapped /free-tools/* paths. The HTML routes cache for 300 seconds and the logos for 3,600.
Data flow#
A Layer A hit#
getToolCost(type)resolves the credit cost.enforceAiQuotaruns. The quota gate is not skipped.incrementUsageruns. The customer is charged the full credit.mvSeoCacheBumpHitsincrements the row'shitscolumn.mvBumpMetricsrecordsruns: 1andcache_hit: 1on scopetool:<type>, andruns: 1on scopeall.- The stored body is returned with
X-MV-Cache: hit,X-MV-Cache-Age-Days: <floor((now - fetched_at) / 86400000)>and anAccess-Control-Expose-Headerslist so the browser can read both.
A Layer A miss#
The real handler runs, metrics record cache_miss: 1, and on success the entire response text is written through with mvSeoCachePut. Only real payloads are stored: the write is skipped when the body has no data field or carries an error, so a degraded response can never be pinned for ten days.
Effect on credits and cost#
| Layer A hit | Layer B hit | Miss | |
|---|---|---|---|
| Customer credits charged | Yes, the full tool cost | Yes, the handler still runs | Yes |
| Quota gate applied | Yes | Yes | Yes |
| Provider call made | No | No, for the cached endpoints | Yes |
metrics_hourly | cache_hit + 1 | Not recorded separately | cache_miss + 1 |
| Response headers | X-MV-Cache, X-MV-Cache-Age-Days | None | None |
Administrator overrides#
The Tools and Cache panel controls Layer B only.
| Endpoint | Auth | Effect |
|---|---|---|
POST /api/admin/tools | Any admin | Returns { tool, cost, section, ttls, override, bindable } for every key in TOOL_CREDIT_COST, plus globalDays and sectionOrder |
POST /api/admin/config/set with cache_days:<tool> | Owner only | Sets or clears one tool's override and busts the 60-second config cache |
POST /api/admin/cache/setall | Owner only | Writes cache_days_default and deletes every cache_days:% key so nothing shadows it. days must be 1 to 365, otherwise HTTP 400 days must be between 1 and 365 |
POST /api/admin/cache/clear | Owner only | With tool, DELETE FROM dfs_cache for that tool. Without it, empties all nine cache tables and returns a per-table tables map |
POST /api/admin/cache/stats | Any admin | Returns the last cache_sweep_last snapshot: rows held per table and what the last sweep removed |
TOOL_CACHE_TTLS is the map the admin console reads to show real lifetimes. It holds the actual callDataForSEOCached TTL literals for 31 tools, taken from a source audit rather than guessed, and a tool can appear with several values because it hits several endpoints. A tool absent from that map makes no cached DataForSEO calls at all and is displayed as Not cached.
Eight tools cannot be tuned individually: seo_brief, article_outline, article_writer, content_template, content_optimizer, content_repurpose, meta_generator and press_pitch. All eight route through the single fetcher fetchRealContentContext, which calls callDataForSEOCached without the fifth tool argument, so their rows carry tool = NULL and are shared. The UI labels them Set via Apply to all with the tooltip Shares one cached fetch with related tools — use Apply to all above. The operator's view of all this is in Tools and cache management.
Failure modes#
| Failure | Behavior | Consequence |
|---|---|---|
| D1 unavailable during a Layer B lookup | callDataForSEOCached catches and falls through to an uncached callDataForSEO | Freshness degrades, correctness does not. Provider spend rises |
| D1 unavailable during a Layer A lookup | The lookup is inside a try; a miss is assumed | The handler runs normally |
platform_config unreadable | mvConfigAll fails open and returns an empty map | Every override disappears for up to 60 seconds and defaults apply |
A bad cache_days value | mvCacheTtlSec falls back to the default | Freshness only |
| A degraded provider response | Not written through, because the write requires parsed.data and no error | The next run retries for real |
| Expired rows | Deleted by the sweep_caches cron job | See below |
The cache sweeper#
sweep_caches (mvSweepCaches) runs on every cron tick and deletes expired rows from all nine cache tables. Before it existed none of them had a delete path of any kind, and because they share one D1 instance with the live application tables, their growth was everyone's problem rather than the caches' own. idx_seo_cache_expires had been sitting on mv_seo_cache(expires_at) the whole time — an index whose only purpose is to make exactly this sweep cheap.
Each table keeps its own rule. Flattening them to one number would delete rows an operator deliberately configured to live longer, and the only symptom would be a larger provider bill.
| Table | Column | Rule |
|---|---|---|
dfs_cache | cached_at | Per tool. Resolved from cache_days:<tool>, then cache_days_default, then 1 day — the same precedence mvCacheTtlSec reads. Rows with tool = NULL take the default |
mv_seo_cache | expires_at | Per row; the table stores its own expiry |
dfs_hist_cache | cached_at | 7 days |
gsc_cache | cached_at | 1 hour. Its write is a plain INSERT, so a row accumulates per miss and only the newest is read — the sweep is what bounds it |
benchmarks_cache | cached_at | 30 days |
quickview_cache | cached_at | 24 hours |
brief_cache | created_at | 7 days. Keyed by day and gated on MV_BRIEF_VERSION, so a past day is already unreachable |
opportunities_cache | created_at | 30 days. Same shape, keyed by week |
mv_translation_cache | created_at | 365 days. A judgement call, not a TTL: this table has none. Its read never checks age because a translation does not go stale, and deleting a row costs a provider call to recreate. A year bounds the table without meaningfully paying for re-translation. If cost appears here, bound it by row count rather than shortening this |
Every delete is chunked at 5,000 rows per statement, the same shape as purge_watchtower, so a first run against a large backlog drains over several ticks instead of attempting one long-running statement.
Once every six hours the sweep also records a snapshot of what each table holds into platform_config under cache_sweep_last, readable through POST /api/admin/cache/stats. Nothing else reports these sizes, which is how nine tables came to have no delete path without anyone noticing. COUNT(*) is a scan, hence the six-hour throttle rather than a per-tick gauge.
See Background jobs and scheduling for the job registry and Data model for the full retention matrix.
Warning: clearing dfs_cache from the admin console is safe but not free. The confirmation copy says so: Clear ALL cached provider data now? The next runs will fetch fresh (higher provider cost). Every subsequent run pays the provider again until the cache refills.
Forcing a fresh result#
forceFresh on the request body bypasses Layer A only. Layer B still applies, so a forced run can still return provider data that is up to its TTL old. The recommendations panel sets forceFresh: true on every call, because its query is a findings blob rather than a user query and caching it would be meaningless.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.