Lesson 14 · The Read Surface · Deeper Track

Reading the graph on demand

The query-api, the operator UI, and the read discipline that keeps it all cheap. ~11 min.

Builds on: L2 · L11 · L13 New: read vs write asymmetry New: HTTP service template

Everything until now was the write side — building and maintaining the graph. But a risk graph nobody can query is useless. This lesson is the read surface: how something asks "what's the risk of address 0xabc?" and gets an answer in milliseconds. The headline insight is an asymmetry: writes are hard and funnelled through one pod; reads are easy, cheap, and can be served by many — by design.

1 · An honest look: query-api is a scaffold (and that's a lesson)

The query-api binary (cmd/query-api/main.go) is the intended external read API. Reading the actual source, you find a production-grade HTTP service shell — but the graph endpoints aren't built yet:

// cmd/query-api/main.go
mux.Handle("GET /metrics", otelResult.MetricsHandler)
mux.HandleFunc("GET /healthz", ...)        // liveness
mux.HandleFunc("GET /readyz", ...)         // readiness
mux.HandleFunc("GET /api/v1/pipeline/status", ...)
// TODO: add graph query endpoints
Reading a real codebase ≠ reading docs
This is a genuinely useful thing to internalise: infrastructure-first scaffolding is normal. The team stood up a fully-instrumented, deployable service (it's in docker-compose on :8080) and will fill in endpoints later. A deep reader notices "the shell is real, the graph reads are TODO" rather than assuming a binary named query-api already serves graph queries. The code is the truth — including its TODOs.

What the scaffold teaches: the canonical HTTP service shape

Even as a shell, it's the template for every HTTP service here, and it's wired into the observability you learned in L11:

PieceWhat it gives you
otelhttp.NewHandler(...)A server span per request + W3C traceparent extraction — an inbound request continues a distributed trace (L11) right into the handler.
httpMetricsMiddlewarePer-request method/route/status metrics — the RED signals (L11) for the API.
GET /healthz · /readyzLiveness vs readiness — k8s probes (readyz will check Neo4j connectivity).
Go 1.22 method routing"GET /path" patterns; clean span names per matched route.
graceful shutdownserver.Shutdown on signal — drain in-flight requests, no dropped reads.

Source: cmd/query-api/main.go (otelhttp, metrics middleware, healthz/readyz, graceful shutdown, the TODO).

2 · So where are graph reads served today?

If query-api is a shell, the live read surfaces are:

SurfaceWho/whatReads
admin-panelcmd/admin-panel — internal operator UI, auth0-gatedCypher-backed views: nodes, roles, flows (documented per-feature in cmd/admin-panel/docs/).
rule-engine APIpkg/rules runHTTP (L12)Rule CRUD, schema, dry-run against the current graph.
NodeLookuppkg/rules/node_lookup.go (internal, for eval)Per-id RiskNode fetches with a TTL cache.
Access control belongs on the read side
Note the admin-panel is auth0-gated — the write path never faces a user, but reads expose data, so the read surface is where authentication/authorization lives. A different concern than anything on the write side.

3 · The deep part: the read discipline

Here's what actually matters and transfers everywhere. The graph is ~1.8M nodes / ~1.5 GiB. A read endpoint that loaded it, or scanned it, would die. Three disciplines keep reads cheap — and you've met all three:

① Never load the whole graph — look up only what you touch

NodeLookup exists precisely to avoid loading the full graph for the few hundred nodes a query actually needs:

// pkg/rules/node_lookup.go
// "serves per-id RiskNode reads … instead of loading the full graph
//  (1.8M nodes, ~1.5 GiB heap) … with just the nodes the rules
//  actually touch (typically a few hundred)."
func (nl *NodeLookup) Get(id) *risk.RiskNode { // per-id, TTL-cached; nil = doesn't exist }

This is L2's "anchor every query, no full scans" rule, embodied as a type.

② Reads are cheap because the work was done at write time

A read of "what's the risk of 0xabc?" does not compute risk — it fetches a pre-stamped property. The risk engine already computed node_risk_score, admin_risk_usd, etc. (L13) and wrote them onto the node. The read is a property lookup, not a calculation. The system pays the cost once, at write time, so every read is fast. (Live fields like balance read the Redis cache + RPC fallback — still no graph compute.)

③ Cache + TTL for the hot set

NodeLookup caches results with a TTL so a churn of repeatedly-read monitored nodes doesn't re-hit Memgraph every time, while the TTL still lets values refresh. Same caching instinct as the L7 RPC cache and the L8 Redis balance cache.

4 · The read/write asymmetry (the headline)

✍️ Writes (L4·L9)

  • One leased graph-writer (no OCC)
  • Serialized, ordered, atomic w/ cursor
  • Hard: idempotent, contiguous, parity-locked
  • The bottleneck you protect

📖 Reads (this lesson)

  • Many stateless readers, scalable
  • Anchored, bounded, cached
  • Cheap: pre-stamped fields, per-id lookups
  • No coordination needed
Why this asymmetry is deliberate & deep
Because Memgraph is the single source of truth (L8) and risk is pre-computed (L13), reads need no coordination — you can run as many read services as you like and they never conflict. The expensive, careful machinery (single-writer, OCC avoidance, parity) is all on the write side so that the read side can be trivially simple and horizontally scalable. This is the classic CQRS-flavoured split: optimise writes and reads separately because they have opposite shapes.

Your anchor
It's like the difference between building an index of the chain (slow, careful, one writer) and querying it (fast, many clients). You've used plenty of read APIs over chain data; this is what the serving side of one looks like when the heavy analysis was pre-computed into the nodes.

Check yourself

1. Reading cmd/query-api/main.go, what do you actually find?
2. The otelhttp.NewHandler wrapper does what that ties to L11?
3. Why does NodeLookup fetch per-id instead of loading the graph?
4. A read of "risk of 0xabc?" is fast because…
5. The read/write asymmetry is: writes go through one leased writer; reads…
6. Where does authentication/authorization live, and why there?
7. The overall split (careful single-writer; cheap scalable readers) is an instance of…
8. Which surfaces actually serve graph reads today?
↳ Ask your teacher
Try: "Show me a real Cypher-backed admin-panel controller," · "How does auth0 gate the admin-panel?" · "What would a good graph query endpoint look like if I built one?" · "How does NodeLookup's TTL cache invalidate?"

What you can now do

Layer complete — both sides of the graph
You now understand the graph from both directions: the careful, serialized write machinery (L1–L10) and the cheap, scalable read surface (here) — plus why they look so different. The only thing left to make the read side concrete is building an endpoint, which is phase-2 territory whenever you want it.

Grounded in: cmd/query-api/main.go (HTTP scaffold: otelhttp, metrics middleware, healthz/readyz, graceful shutdown, the graph-endpoints TODO), cmd/admin-panel/ (auth0-gated operator UI), pkg/rules/node_lookup.go (per-id RiskNode reads, TTL cache, "1.8M nodes / 1.5 GiB" rationale), pkg/rules/engine.go (runHTTP). Verify against source — the code is the truth.