Skip to content
Metric VaultHelp Center
Open app

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#

text
   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 · metrics

Everything 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.

PropertyValue
Key`'v1\' + mvToolParamsKey(body)`
Key partstype, 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 keyNone. That is what makes it cross-customer
Stored valueThe entire /api/premium-ai response body, verbatim
ExpiryRead-time only: expires_at > Date.now()
Admin overrideNone. 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.

TTLTools
10 daysdomain_overview, backlink_profile
7 dayslocal_seo, audience_overlap, pla_research, influencer_analysis, keyword_overview, keyword_magic, topic_research, content_gap, ai_questions
5 daystraffic_intel, organic_research, top_pages, ppc_research, advertising_research, market_explorer, ad_clarity
3 dayscontent_decay
2 daysbrand_performance, social_analytics, ai_competitor_research, ai_visibility, perception, narrative_driver, prompt_research, forum_visibility
1 daysite_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 hourscontent_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).

PropertyValue
Keyendpoint + ':' + JSON.stringify(body)
Stored valueJSON.stringify(tasks[0].result)
WriteINSERT OR REPLACE
Timestampcached_at in seconds
tool columnOptional fifth argument. NULL when the call site omits it

TTL resolution order, implemented in mvCacheTtlSec:

  1. platform_config['cache_days:<tool>'], if the tool argument was passed and that key exists.
  2. platform_config['cache_days_default'].
  3. The caller's literal ttlSec argument.
  4. 86400 (one day) when ttlSec is 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#

TableKeyed byTTLNotes
dfs_hist_cache'hist:' + JSON body7 days, hardcodedHistorical rank overview. No override; swept by sweep_caches
benchmarks_cacheNormalised domain30 daysPowers the "How you compare" panel
quickview_cacheNormalised domain24 hoursChrome extension. Responds with X-Cache: HIT or MISS
mv_translation_cache`sha256(src\tgt\text)`NoneEntries are permanent by design
brief_cache(user_email, day, target)One UTC dayThe daily brief fires on every dashboard load, so it must never charge credits or re-hit the provider
gsc_cacheperf:<site>:<start>:<end>:<dimensions>1 hourCleared 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#

  1. getToolCost(type) resolves the credit cost.
  2. enforceAiQuota runs. The quota gate is not skipped.
  3. incrementUsage runs. The customer is charged the full credit.
  4. mvSeoCacheBumpHits increments the row's hits column.
  5. mvBumpMetrics records runs: 1 and cache_hit: 1 on scope tool:<type>, and runs: 1 on scope all.
  6. The stored body is returned with X-MV-Cache: hit, X-MV-Cache-Age-Days: <floor((now - fetched_at) / 86400000)> and an Access-Control-Expose-Headers list 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 hitLayer B hitMiss
Customer credits chargedYes, the full tool costYes, the handler still runsYes
Quota gate appliedYesYesYes
Provider call madeNoNo, for the cached endpointsYes
metrics_hourlycache_hit + 1Not recorded separatelycache_miss + 1
Response headersX-MV-Cache, X-MV-Cache-Age-DaysNoneNone

Administrator overrides#

The Tools and Cache panel controls Layer B only.

EndpointAuthEffect
POST /api/admin/toolsAny adminReturns { 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 onlySets or clears one tool's override and busts the 60-second config cache
POST /api/admin/cache/setallOwner onlyWrites 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/clearOwner onlyWith 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/statsAny adminReturns 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#

FailureBehaviorConsequence
D1 unavailable during a Layer B lookupcallDataForSEOCached catches and falls through to an uncached callDataForSEOFreshness degrades, correctness does not. Provider spend rises
D1 unavailable during a Layer A lookupThe lookup is inside a try; a miss is assumedThe handler runs normally
platform_config unreadablemvConfigAll fails open and returns an empty mapEvery override disappears for up to 60 seconds and defaults apply
A bad cache_days valuemvCacheTtlSec falls back to the defaultFreshness only
A degraded provider responseNot written through, because the write requires parsed.data and no errorThe next run retries for real
Expired rowsDeleted by the sweep_caches cron jobSee 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.

TableColumnRule
dfs_cachecached_atPer 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_cacheexpires_atPer row; the table stores its own expiry
dfs_hist_cachecached_at7 days
gsc_cachecached_at1 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_cachecached_at30 days
quickview_cachecached_at24 hours
brief_cachecreated_at7 days. Keyed by day and gated on MV_BRIEF_VERSION, so a past day is already unreachable
opportunities_cachecreated_at30 days. Same shape, keyed by week
mv_translation_cachecreated_at365 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

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?