Cache Observability & Tuning
This chapter is the operator’s field guide to the governed cache: every metric it exposes, the four surfaces that expose them, and a symptom → knob playbook for turning what you see into a configuration change.
It is the practical companion to Caching & Memory Tuning, which explains why the cache exists and how its memory budget is partitioned. Read that first for the concepts (the budget model, the governor, the four layers); read this to watch the cache in production and size it right. The raw knob table lives in Configuration → Cache memory budget.
Nothing to see while off. The cache is opt-in (
PATINADB_CACHE_LIMITunset or0— the default). While disabled every surface below is a truthful all-zeros “just the OS/redb page cache” report, never an error, at zero residual cost.
The four surfaces
The same per-scope accounting is exposed four ways, from quickest to richest:
| Surface | Reach for it when | Detail |
|---|---|---|
CALL patinadb.cache.stats() | you are already in a query session and want a fast per-scope look | one row per (level, scope): bytes, entries, hit rate |
GET /mgmt/cache | you want the whole report as JSON (scripts, ad-hoc curl, the browser proxy) | budget + per-level + per-scope + governor + Sankey + live free RAM |
Prometheus /metrics | you want time-series, alerting, and the Grafana dashboard | the patinadb_cache_* gauge/counter set |
patinadb-browser admin → Cache | you want a live visual, including the read-flow Sankey | fill gauges, per-level bars, hot-scope table, Sankey |
All four read the same process-global governor, node-local by design — a node reports only its own cache. The cache is never replicated, so each node’s numbers stand alone (that is exactly why a per-node cache can never cause divergence — see Caching → The governor).
Metric reference
Every metric, grouped by what it tells you, with its name on each surface it
appears on. A — means that surface does not expose it. The Prometheus column is
the exact series name — a wrong name reads nothing, so these are verbatim.
Labels: {level} is the cache layer (l1.objects, l1.properties,
l2.adjacency, l3.results); {db} is the numeric database id.
Fill — how much RAM the cache holds
| Meaning | Prometheus | cache.stats | /mgmt/cache | How to read it |
|---|---|---|---|---|
| Resident bytes per level (and per db) | patinadb_cache_bytes{level,db} | bytes (per scope) | levels[].bytes, total_resident_bytes | Growing bytes with a healthy hit ratio = the cache is earning its RAM |
| Resident entry count per level | patinadb_cache_entries{level} | entries (per scope) | levels[].entries | Entries × avg-entry-bytes ≈ bytes; a spike in entries with flat bytes = many small objects |
| Fill vs. the cap | patinadb_cache_utilization_ratio{level} | — | levels[].utilization, top-level utilization | Each level’s bytes / cache_limit; the levels sum to the overall fill. ~1.0 = full |
| Mean bytes per entry | — | — | levels[].avg_entry_bytes | Sizing sanity check — an L3 result row is far larger than an L1 object |
| The resolved hard cap | patinadb_cache_limit_bytes | — | budget.cache_limit | The ceiling the sum of all levels is kept under |
Hit / miss — is the cache paying off
| Meaning | Prometheus | cache.stats | /mgmt/cache | How to read it |
|---|---|---|---|---|
| Cache hits (skipped a decode) | patinadb_cache_hits_total{level,db} | via hit_rate | levels[].hits, scopes[].hits | A hit is a decode + string-alloc avoided |
| Cache misses (fell through to storage) | patinadb_cache_misses_total{level,db} | via hit_rate | levels[].misses, scopes[].misses, sankey.misses | The fall-through to a real storage decode |
| Hit ratio | patinadb_cache_hit_ratio{level} | hit_rate (per scope) | levels[].hit_rate, scopes[].hit_rate | hits / (hits + misses). The single headline number per level / scope |
cache.stats reports hit_rate per (level, scope) — the finest grain, so
you can see which collection is hot. The Prometheus hit_ratio is the per-level
aggregate; /mgmt/cache carries both.
Eviction — is the cache under pressure
| Meaning | Prometheus | cache.stats | /mgmt/cache | How to read it |
|---|---|---|---|---|
| Entries evicted by LRU/capacity, per level | patinadb_cache_evictions_total{level} | — | levels[].evicted_entries | Rising alongside a low hit ratio = the working set doesn’t fit |
| Bytes freed by those evictions, per level | — | — | levels[].evicted_bytes | The byte-weight of the per-level LRU churn |
| Bytes evicted to protect the page-cache floor | patinadb_cache_evicted_free_floor_bytes_total | — | governor.evicted_free_floor_bytes | The page-cache-pressure signal. Non-zero = the governor is shrinking the app cache to keep the OS’s L0 resident (see tuning) |
| Bytes evicted to enforce the hard cap | patinadb_cache_evicted_cap_bytes_total | — | governor.evicted_cap_bytes | The cache hit cache_limit with RAM to spare — you can afford a bigger cap |
The free-floor-vs-cap split is the most operationally important pair here.
Both are eviction, but they mean opposite things: cap eviction says the cache
is bounded by your PATINADB_CACHE_LIMIT (raise it if you have RAM);
free-floor eviction says the cache is bounded by the OS running low on free
RAM (the cache is starving the page cache — shrink it).
Admission — is scan-resistance working
| Meaning | Prometheus | cache.stats | /mgmt/cache | How to read it |
|---|---|---|---|---|
| Candidates admitted | patinadb_cache_admissions_total | — | governor.admissions | The steady flow of newly-cached decoded artifacts |
| Candidates rejected (scan-resistance) | patinadb_cache_rejections_total | — | governor.rejections | A rejection is a one-shot scan element kept out of the hot set — healthy. Rises when a full scan streams cold keys past a warm, cap-full cache (victim-aware W-TinyLFU admission) |
Invalidation — the write-churn tax
| Meaning | Prometheus | cache.stats | /mgmt/cache | How to read it |
|---|---|---|---|---|
| Entries dropped by a stale generation on lookup | patinadb_cache_gen_invalidations_total{level} | — | levels[].gen_invalidations | Lazy invalidation: a write bumped a label’s generation, so its cached entries miss on next read. High on a scope = that label is write-churny |
| Entries dropped by a proactive scope invalidation | — | — | levels[].scope_invalidations | Eager drops from clear_graph, db-drop, an edge write into L2, or the same-txn write window |
Both count the same thing from two directions — the cost of a write to cached
data. gen_invalidations is the passive, next-read discovery; scope_invalidations
is the active, up-front purge. A scope with high invalidation and a low hit ratio
is telling you the cache cannot help that data (it changes faster than it is
re-read) — expected, and not worth budget (see
Caching → Honest limits).
Budget & free RAM — the governor’s operating envelope
| Meaning | Prometheus | cache.stats | /mgmt/cache | How to read it |
|---|---|---|---|---|
| The system free-RAM floor the governor protects | patinadb_cache_min_free_bytes | — | budget.min_free | The promise to the OS: keep at least this much system RAM free for redb’s L0 |
Live system free RAM (MemAvailable) | patinadb_mem_available_bytes | — | mem_available_bytes | The ground truth the sampler watches. Hovering near min_free = pressure |
| Own-heap ceiling | — | — | budget.total | patinaDB’s own-heap limit (excludes the OS page cache) |
| Action reserve | — | — | budget.work_mem_limit | RAM reserved for concurrent query working memory |
| Headroom | — | — | budget.headroom | The transient-spike / OOM safety margin |
The pair to watch together is patinadb_mem_available_bytes against
patinadb_cache_min_free_bytes: the gap between them is the governor’s remaining
slack before it starts shrinking the app cache to defend the page cache.
L4 disk victim tier
The L4 victim cache is the optional disk tier below the RAM L3 result
cache: when an expensive L3 result is evicted (or admission-rejected), it is
spilled to a redb file under the db root and served from disk on a future
identical query instead of recomputed. It is off by default
(PATINADB_L4_VICTIM_MAX_BYTES=0), so its whole metric block is absent until
you enable it. Enabled, it reports as a distinct block — it is not a governed
RAM level, so it never shows up under levels[] or the RAM cache_limit.
| Meaning | Prometheus | cache.stats | /mgmt/cache | How to read it |
|---|---|---|---|---|
| Resident disk entries / bytes | patinadb_l4_victim_entries / patinadb_l4_victim_bytes | l4.victim row (entries/bytes) | l4_victim.entries / .bytes | Fill against the configured max_bytes cap |
| Valid disk hits (served + promoted) | patinadb_l4_victim_hits_total | l4.victim row hit_rate = hits/(hits+stale_drops) | l4_victim.hits | Each hit skipped a full recompute of an expensive query |
| Hits promoted back to RAM | patinadb_l4_victim_promotions_total | — | l4_victim.promotions | An L4 hit re-enters L3 (a proven repeat) |
| Victims spilled to disk | patinadb_l4_victim_spills_total | — | l4_victim.spills | Expensive results caught on evict/reject. spills ≫ hits = you are paying disk churn for results that never get reused — raise PATINADB_L4_VICTIM_MIN_COST_MS or lower the cap |
| Entries dropped stale on read | patinadb_l4_victim_stale_drops_total | — | l4_victim.stale_drops | The persisted generation stamp mismatched (a write touched an involved label) — high = the cached labels are write-churny (L4 can’t help them) |
| Entries LRU-evicted over the disk cap | patinadb_l4_victim_evictions_total | — | l4_victim.lru_evictions | The disk tier is at max_bytes — the coldest entries are dropped first |
The headline pair is spills vs hits: L4 is earning its keep when hits are a
healthy fraction of spills. If spills dominate, the workload is expensive-but-not-
repeated (or too write-churny — see stale_drops), and the disk tier is pure
overhead. The writer runs on a bounded background channel off the read/evict
hot path, so a slow disk never stalls a query — under backpressure a spill is
simply dropped (a future recompute, never a wrong answer).
Reading each surface
In-query: CALL patinadb.cache.stats()
The fastest look — no HTTP, no dashboard, runs in any session:
CALL patinadb.cache.stats()
YIELD scope, kind, bytes, entries, hit_rate, generation
RETURN scope, kind, bytes, entries, hit_rate
ORDER BY bytes DESC
scope— the hot database / collection / query shape, rendered as a stable string:db:1,db:1/label:Ticket, ordb:1/shape:1234(a plan fingerprint).kind— the cache level (l1.objects,l1.properties,l2.adjacency,l3.results, and — when the disk victim tier is enabled — a singlel4.victimpseudo-scope row).bytes/entries— resident size for that(level, scope).hit_rate—hits / (hits + misses),0.0when never accessed.generation— reserved; yielded asNULLtoday (the column is kept for schema stability).
With caching disabled it returns zero rows (no levels are registered), never an error. See Procedures → Cache observability.
Over HTTP: GET /mgmt/cache
The complete report as one JSON document (admin-only — it is under /mgmt/). This
is the richest single call: it carries fields no other surface has
(avg_entry_bytes, evicted_bytes, scope_invalidations, the full budget, and
the Sankey).
curl -s -u neo4j:secret http://127.0.0.1:21001/mgmt/cache | jq
An enabled node returns roughly:
{
"enabled": true,
"budget": {
"total": 15461882265,
"cache_limit": 6184752906,
"work_mem_limit": 6957846769,
"headroom": 2319282256,
"min_free": 1717986918
},
"total_resident_bytes": 41231360,
"utilization": 0.0067,
"governor": {
"admissions": 128934,
"rejections": 20514,
"evicted_free_floor_bytes": 0,
"evicted_cap_bytes": 0
},
"levels": [
{
"name": "l3.results",
"bytes": 12058624, "entries": 214,
"hits": 90233, "misses": 1201, "hit_rate": 0.9869,
"utilization": 0.0019, "avg_entry_bytes": 56348,
"evicted_entries": 0, "evicted_bytes": 0,
"gen_invalidations": 88, "scope_invalidations": 3,
"scopes": [
{ "scope": "db:1/shape:8123", "db": 1, "bytes": 8388608,
"entries": 40, "hits": 61022, "misses": 210, "hit_rate": 0.9966 }
]
}
],
"sankey": {
"total_lookups": 402118,
"misses": 14002,
"layers": [
{ "name": "l3.results", "label": "L3 result", "value": 90233 },
{ "name": "l2.adjacency", "label": "L2 adjacency", "value": 121444 },
{ "name": "l1.properties","label": "L1 property", "value": 130221 },
{ "name": "l1.objects", "label": "L1 object", "value": 46218 },
{ "name": "miss", "label": "miss → storage","value": 14002 }
]
},
"mem_available_bytes": 9663676416
}
The levels array is ordered deepest-cache-first (L3 → L2 → L1 property → L1
object) — the same order the Sankey reads. Each level’s scopes are sorted
hottest-first (by hits, then bytes) so the top row is the hottest collection or
shape. The sankey block is the read-flow: total_lookups is the source width
(Σ hits + Σ misses), each layer’s value is the hits that layer absorbed,
and the trailing miss layer is the summed fall-through to a storage decode.
Because the levels are consulted independently (a query may touch several), it is
an aggregate share of all cache lookups, not a strict per-query cascade.
While disabled, enabled is false, the budget is all zeros, and levels is
empty. See REST API → GET /mgmt/cache.
Prometheus & Grafana
/metrics exports the full patinadb_cache_* set (refreshed from the governor at
scrape time — always current, no background task). The Docker demo in deploy/
ships a ready-made patinaDB Cache dashboard
(grafana/dashboards/patinadb-cache.json, 20 panels); it is auto-provisioned by
the compose stack, so once a node is scraped it appears in Grafana with no import
step. To load it into an existing Grafana, import that JSON and point it at your
Prometheus datasource. Its three panel rows map onto the metric groups above:
- Cache Overview — five stat tiles: utilization %
(
sum(patinadb_cache_bytes) / max(patinadb_cache_limit_bytes)), resident bytes, cached entries, mem-available, and the overall hit ratio (rate(hits) / (rate(hits) + rate(misses))). - Fill & Hit Rate — resident bytes / utilization / hit-ratio / entries per
level, the hit-vs-miss rate, and a hot databases table keyed by
patinadb_cache_bytes{level,db}. - Eviction, Admission & Invalidation — the free-floor-vs-cap eviction-byte
split (
rate(patinadb_cache_evicted_free_floor_bytes_total)vs..._cap_bytes_total), evicted entries and generation invalidations per level, admissions vs rejections, the miss → storage decode rate, and mem-available vs the free floor — the single most important page-cache-pressure panel.
The dashboard is all-zero until the cache is enabled. The full compose stack and
its two provisioned dashboards are documented in the repo’s deploy/README.md.
Browser admin → Cache
The patinadb-browser admin dashboard has a Cache section (it proxies the
node’s GET /mgmt/cache) with, at a glance:
-
KPI tiles — resident / cache-limit and utilization, governor admissions & rejections, the floor-vs-cap eviction bytes, and live mem-available vs the free floor.
-
Per-level bars — one row per level with a hit/miss track and the raw hits / misses / evictions / gen-invalidations / utilization.
-
Hot scopes — the top scopes across all levels (which db / collection holds the hot bytes).
-
The Layer Sankey — “Which layer catches how much”: one ribbon per cache layer sized by the hits it absorbed, plus a
miss → storagefall-through:Cache lookups ─┬─▶ L3 result ├─▶ L2 adjacency ├─▶ L1 property ├─▶ L1 object └─▶ miss → storageA fat
L1 propertyribbon and a thinmiss → storagetail is the healthy read-heavy picture — most reads are absorbed above the storage decode. A fatmiss → storageribbon means the cache is not catching your read pattern (too small, or the workload is write-churny / scan-heavy). In embedded (file) mode there is no server, so the section shows cache disabled.
Tuning from the metrics
This is the payoff: mapping what a surface shows to the knob that fixes it. Every
knob below is documented in
Configuration → Cache memory budget
(each is an env var, a patinadb-raft flag, and a YAML key).
| What you see | What it means | What to do |
|---|---|---|
Low hit ratio + rising evictions_total | The working set is bigger than the cache — entries are evicted before their second hit | Raise PATINADB_CACHE_LIMIT (you have RAM to spend) |
Non-zero evicted_free_floor_bytes_total + mem_available hovering near min_free | The app cache is starving the OS page cache; the governor is shrinking it to defend redb’s L0 | Lower PATINADB_CACHE_LIMIT (or PATINADB_MEMORY_LIMIT), or raise PATINADB_CACHE_MIN_FREE to give L0 a bigger floor |
Rising evicted_cap_bytes_total while mem_available stays healthy | The cache is bounded by your cap, not by RAM pressure — there is free RAM going unused | Raise PATINADB_CACHE_LIMIT to let the cache grow into the free RAM |
High gen_invalidations / scope_invalidations on a scope + low hit_rate there | That label is write-churny; its entries die before a second read | Expected — nothing to tune. The cache correctly declines to spend budget on it; don’t force it |
Rising rejections_total | Admission (scan-resistance) is keeping a one-shot scan out of the hot set | Healthy — no action. This is the cache protecting your OLTP working set from an analytics scan |
Near-100% utilization + high hit_ratio + low eviction rate | Well-sized: the cache is full of hot data and rarely churns | Leave it. Grow the cap only if the hit ratio starts to dip |
Fat miss → storage Sankey ribbon | Most reads fall through to a storage decode | Cache too small (raise the limit) or the workload is genuinely write-/scan-heavy (accept it, or see When to disable) |
The two failure modes worth internalizing are the mirror image of each other:
- Too small shows as low hit ratio + cap eviction + a fat miss ribbon while
RAM is free → raise
PATINADB_CACHE_LIMIT. - Too big shows as free-floor eviction +
mem_availablepinned atmin_free→ lower it. Oversizing the app cache and starving the OS page cache re-creates the exact swap cliff the cache was meant to avoid — the free-floor split and the mem-available panel exist to make that visible before it bites.
When in doubt, size conservatively and let a high hit ratio justify growing.
If the working set fits RAM with the page cache alone and decode cost is already
negligible, the honest answer is PATINADB_CACHE_LIMIT=0 (the default) — a fully
supported, zero-residual-cost configuration; see
Caching → When to disable.
Current limitations
generationincache.statsis reserved — the per-scope write-generation is not carried on the observability seam yet, so the column is alwaysNULL. It is kept in the signature for schema stability.- Prometheus is a subset of
/mgmt/cache.avg_entry_bytes,evicted_bytes(per level), andscope_invalidationsare only on/mgmt/cache(and the browser) — there is no Prometheus series for them. For alerting on those, scrape the endpoint directly.