The query-api, the operator UI, and the read discipline that keeps it all cheap. ~11 min.
Builds on: L2 · L11 · L13New: read vs write asymmetryNew: 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:
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:
Piece
What 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.
httpMetricsMiddleware
Per-request method/route/status metrics — the RED signals (L11) for the API.
GET /healthz · /readyz
Liveness vs readiness — k8s probes (readyz will check Neo4j connectivity).
Go 1.22 method routing
"GET /path" patterns; clean span names per matched route.
graceful shutdown
server.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:
Rule CRUD, schema, dry-run against the current graph.
NodeLookup
pkg/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 }
② 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
Read a binary honestly — recognise a production HTTP scaffold with endpoints still TODO.
Describe the canonical HTTP service shape here (otelhttp tracing, metrics middleware, healthz/readyz, graceful shutdown) and how it plugs into L11.
Name the live read surfaces (admin-panel, rule-engine API, NodeLookup) and why auth lives on the read side.
Explain the read discipline: never full-scan, pre-computed fields, TTL caches.
Articulate the read/write (CQRS-flavoured) asymmetry and why it's deliberate.
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.