Skip to Content
Data Layer

Data layer

Glow’s read path has two cooperating layers. Materialized views (<artifact>_mv tables in Postgres) precompute the hydrated rows that search and list endpoints return, refreshed in the background on a cadence per artifact. A route-level cache sits in front of those reads in Redis, keyed by the request and busted by tag. Together they turn most list/get reads into sub-50ms responses without coupling mutation paths to read freshness.

Cold search warms the materialized view; the next hits the route cache; a mutation elsewhere fires X-Invalidate-Tags so the following request misses cleanly and re-warms.

Two layers

The split is deliberate:

LayerStorageKeyed byBusted by
Materialized viewsPostgres (<artifact>_mv)Underlying entry tablesBackground refresh on a cadence + POST /<artifact>/refresh
Route-level cacheRedis (http:cache:*)Path + body hashTTL + X-Invalidate-Tags from mutation responses

A list read first checks the route cache; on miss it queries the MV and writes the response back into Redis with a tag set. A mutation in the same artifact enqueues an MV refresh and returns X-Invalidate-Tags, which clients are expected to surface so that the next read bypasses the now-stale cache entry. Reads from MVs are not audited — the Audit ledger only captures mutations.


Materialized views

Almost every artifact in Glow has a paired <artifact>_mv materialized view that joins the canonical entry table with its drafts, junctions, and lookup rows in a single SELECT. Search, list, and most hydration-heavy GET endpoints read only from the MV — never from the raw entry table — so per-request hydration stays O(1) regardless of how many junctions the artifact has accumulated.

The full registry of MV targets lives in core/app/infra/refresh/config.py:MV_REGISTRY. A small sample:

MVBacksRefresh tier
personas_mv, persona_drafts_mvPOST /persona/search, /persona/get, /persona/draftsCool
tests_mv, test_invocation_mvPOST /test/search, /test/invocationsCool
runs_mv, messages_mv, calls_mvLive attempt + agent surfacesHot
groups_mv, group_names_mvSession timeline + every artifact’s /title resolverHot
grants_mv, health_mv, sessions_mvPermission gates + admin dashboardsGlacial

Auto-refresh cadence

Every MV target is assigned a hotness tier — Hot (~2s), Warm (~5s), Cool (~10s), Glacial (~30s) — defined alongside the registry. A per-MV background worker (MVRefresher in core/app/infra/refresh/scheduler.py) debounces enqueues into one REFRESH MATERIALIZED VIEW CONCURRENTLY per interval and serializes across replicas via a Redis lock. The exact interval per MV is configurable in MV_REGISTRY — don’t read the constants here as a contract.

Mutations don’t run the refresh inline. Instead, every create / update / delete primitive calls enqueue_refreshes(...) with the MV targets that depend on it, which just flips an O(1) Redis flag. The worker picks it up on its next tick. This means read freshness lags writes by up to one refresh interval for the artifact’s tier.

Manual refresh: POST /<artifact>/refresh

Every artifact exposes a refresh endpoint that re-enqueues all of its MV targets and invalidates the route-level cache tags for that artifact. Example for personas:

glow personas refresh
curl -X POST $GLOW_INSTANCE_URL/persona/refresh \ -H "Authorization: Bearer $GLOW_TOKEN"

The response carries the list of invalidated tags and the per-target enqueue receipts, and the HTTP X-Invalidate-Tags header echoes the same tag set so client caches react too. The actual MV recompute still happens in the background worker — refresh is not a synchronous “give me a fresh row” call.


Route-level cache

The route cache is a thin Redis-backed wrapper any list / search / hydrated-GET endpoint can opt into. The primitives live in core/app/utils/cache/:

  • cache_key(path, body, user_ctx) — SHA-1 over a stable JSON encoding of {path, body, user_ctx}. The prefix is http:cache:. The user_ctx arg is optional; routes that scope per-profile pass the profile_id, routes that don’t (license-wide list reads) leave it empty.
  • get_cached(key, redis=...) — returns the cached envelope or None.
  • set_cached(key, data, ttl, tags, redis=...) — writes the envelope with a TTL and adds the key to each tag’s Redis set so invalidate_tags can sweep them later.
  • invalidate_tags(tags, redis=...) — drops every cached key registered against any of the tags in one pipeline.

Wiring example

core/app/routes/system/groups.py is the prototypical shape:

tags = ["artifacts", "groups", "list"] bypass_cache = http_request.headers.get("X-Bypass-Cache") == "1" cache_key_val = cache_key(http_request.url.path, request.model_dump(mode="json")) if not bypass_cache: cached = await get_cached(cache_key_val, redis=get_redis_client()) if cached: response.headers["X-Cache-Tags"] = ",".join(tags) response.headers["X-Cache-Hit"] = "1" return ListPricingResponse.model_validate(cached["data"]) api_response = await groups_system_impl(...) await set_cached( cache_key_val, {"data": api_response.model_dump(mode="json")}, ttl=300, tags=tags, redis=redis, ) response.headers["X-Cache-Tags"] = ",".join(tags) response.headers["X-Cache-Hit"] = "0" return api_response

TTLs are per-route — the groups list uses 300s in the snippet above; the “big” page-context cache (core/app/utils/cache/big.py) defaults to 30s. Don’t read either as a global contract; check the call site.

Every cached response carries two headers so clients can self-report:

  • X-Cache-Tags — the tag set this response was stored under, so a client can mirror the server’s invalidation scheme.
  • X-Cache-Hit1 if the response came from Redis, 0 if it was computed and just written.

X-Bypass-Cache

Send X-Bypass-Cache: 1 on any cached read to skip the route cache entirely. The route still runs against the MV (which may itself be stale by up to one refresh interval — see above), but Redis is neither consulted on the way in nor written on the way out.

Use it when:

  • You just performed a mutation in the same client and need a consistent-read for the next render.
  • You’re debugging a “why is this stale” report and want to rule out Redis as the source.
  • You’re running a test that asserts on freshly written rows.

Do not use it as a default — it negates the whole point of the cache layer.

X-Invalidate-Tags

Mutation responses (and the /refresh endpoint of every artifact) return an X-Invalidate-Tags header listing the tag families that the write touched. Examples observed in the routes:

  • POST /persona/updatepersonas
  • POST /persona/refreshpersonas,artifacts
  • POST /test/gradetest,tests,grades
  • POST /document/file_uploaduploads,resources,files

Clients that maintain their own caches (the Glow UI does, via useArtifactGeneration and the shared generation listener) read this header on every response and drop their local entries for the listed tags. The server-side equivalent is invalidate_tags(...) from core/app/utils/cache/invalidate_tags.py, which the same mutation paths call inline before returning.

When to call /<artifact>/refresh manually

ScenarioManual /refresh?Why
Routine mutate-then-read through the SDKNoAuto-enqueue + tag invalidate already cover this within one tick
Just finished a bulk import / CSV loadYesOne refresh collapses N enqueues; UI tables can re-fetch immediately
Background MV drift suspected during incident triageYesForces an enqueue regardless of the next-run-at gate
About to snapshot a list view in a test or reportYesPairs naturally with X-Bypass-Cache: 1 on the follow-up read
Recovery after a worker crashNoThe Redis pending flag survives — the next worker tick drains it

The refresh endpoint is cheap (it just flips a Redis flag and writes an audit row); the actual REFRESH MATERIALIZED VIEW cost is paid once per debounce window regardless of how many times you call it.

  • POST /persona/refresh — the canonical refresh endpoint, one per artifact
  • Streaming — refresh emits a .completed event on the artifact watch channel when the MV recompute finishes
  • Audit — cache hits and MV refresh ticks are intentionally not audited; only the mutations that trigger invalidation land in the ledger
  • Activity — backs onto activity_mv, the same refresh + cache contract as every other list surface
Last updated on