Example 25 — Software-House Task Graph (persistent REST API)
A small, production-shaped REST service built on GoGraph. It models task
management inside a software-house as a single multi-layer Labeled Property
Graph (LPG) and answers the questions a maintenance team actually asks —
change impact, code ownership, bus-factor, workload, blocked work, dependency
cycles — in Cypher, over one graph that spans code, work and people.
It is built on the Go standard library only (net/http, zero new
dependencies) and is persistent across restarts and kill -9 safe
(write-ahead log + snapshot + recovery).
It also demonstrates GoGraph's schema surface: Cypher DDL (CREATE CONSTRAINT / CREATE INDEX), UNIQUE-constraint enforcement (409 on a
duplicate), schema introspection via the built-in db.* procedures
(GET /schema), and query-plan explanation (POST /explain, index seek vs
label scan) — all of it durable across a restart.
- Data model, REST contract and query catalogue:
SPEC.md
- Runtime reference: the package doc in
doc.go
The multi-layer model
Three layers live in one LPG. Every node carries a layer label
(Code / Work / People) and a type label. Two inter-layer edges,
ASSIGNED_TO and TOUCHES, form the spine that makes cross-layer questions a
single Cypher pattern.
People layer Work layer Code layer
┌────────────┐ ASSIGNED_TO ┌──────┐ TOUCHES ┌───────────┐
│ Developer │──────────────▶│ Task │────────────────▶│ Component │
│ Team │ │ Sprint │ Module │
└────────────┘ │ WorkflowState │ Repository│
MEMBER_OF └──────┘ └───────────┘
SUBTASK_OF / NEXT CONTAINS / DEPENDS_ON
BLOCKS / HAS_STATE
IN_SPRINT
DEPENDS_ON (dependent → dependency) is the dependency graph; composed with the
spine it answers "if I change X, who is affected?". Completed work carries
TOUCHES edges (realised history); planned work carries only
ASSIGNED_TO {state:'planned'}. See SPEC.md for the full schema.
Build and run
# from the repository root — small deterministic fixture
go run ./examples/25_software_house_api -d ./data -addr :8080
# observable-scale run: a seeded synthetic graph (~5.7k nodes, ~19k edges)
go run ./examples/25_software_house_api -d ./big -addr :8081 \
-scale-components 2000 -scale-tasks 1500 -scale-developers 80 -scale-seed 7
-d is the data directory (created if missing); -addr is the listen address
(default :8080). Press Ctrl-C (SIGINT) or send SIGTERM for a graceful
shutdown that writes a final snapshot and closes the WAL.
Scale and flags
| Flag |
Meaning |
Default |
Representative large value |
-d <dir> |
Data directory (WAL + snapshot) |
(required) |
— |
-addr <host:port> |
HTTP listen address |
:8080 |
— |
-scale-components <n> |
Extra synthetic :Component nodes |
0 (bare fixture) |
2000 |
-scale-tasks <n> |
Extra synthetic :Task nodes |
0 |
1500 |
-scale-developers <n> |
Extra synthetic :Developer nodes |
0 |
80 |
-scale-seed <s> |
RNG seed fixing the synthetic shape |
1 |
7 |
With every -scale-* flag at 0 the server loads exactly the 46-node /
106-edge hand-authored fixture below — the small deterministic default the
regression tests pin. Setting any -scale-* flag grows the same multi-layer
model with a seeded, reproducible synthetic layer (keys prefixed syn:, so
no hand-authored key is ever touched), up to a size where query latency, live
heap, and bytes-per-element become observable. The same scale can be requested
per call via the POST /seed body (see below). The synthetic topology is
described in synth.go: a Price-model power-law dependency DAG with
a bounded number of injected cycles, ownership clustered by developer
home-module affinity (so bus-factor risk is realistic), and shallow BLOCKS
chains — so every maintenance query stays meaningful at scale.
Endpoints
| Method & path |
Purpose |
POST /query |
Run an arbitrary Cypher statement (read or write). |
POST /seed |
Idempotently load the fixture, optionally at a synthetic scale. |
POST /explain |
Return the physical plan for a query (index seek vs label scan) without running it. |
GET /stats |
Node/edge counts (facts) plus a volatile telemetry object. |
GET /schema |
The live schema: labels, relationship types, property keys, indexes, constraints. |
GET /healthz |
Liveness probe. |
Seed the graph
curl -s -XPOST localhost:8080/seed
{"seeded":true,"status":"ok","scale_components":0,"scale_tasks":0,"scale_developers":0}
Running it again is a no-op ("seeded":false). The default fixture is
46 nodes and 106 edges across the three layers.
To grow it with a seeded synthetic layer, post the scale in the body:
curl -s localhost:8080/seed -d '{"scale_components":2000,"scale_tasks":1500,"scale_developers":80,"scale_seed":7}'
{"seeded":true,"status":"ok","scale_components":2000,"scale_tasks":1500,"scale_developers":80}
The synthetic layer commits in the same atomic transaction as the base
fixture, so it is applied exactly once and survives a kill -9 (the next reopen
replays the whole seed from the WAL). A given scale_seed reproduces the same
graph byte-for-byte.
Inspect the counts and telemetry
curl -s localhost:8080/stats
The response splits deterministic facts (nodes, edges) from volatile
telemetry (telemetry). The facts are reproducible for a fixed seed and
scale; every telemetry field varies per run and per machine — the JSON analogue
of the # telemetry convention the non-server examples use.
{
"nodes": {"Repository":1,"Module":5,"Component":12,"Task":14,
"Sprint":2,"WorkflowState":4,"Developer":6,"Team":2},
"edges": {"CONTAINS":17,"DEPENDS_ON":17,"SUBTASK_OF":2,"NEXT":4,
"BLOCKS":3,"HAS_STATE":14,"IN_SPRINT":14,"MEMBER_OF":6,
"ASSIGNED_TO":16,"TOUCHES":13},
"telemetry": {
"heap_alloc_bytes": 10590968, "heap_alloc_human": "10.10 MiB",
"heap_sys_bytes": 45449216, "num_gc": 19,
"bytes_per_element": 461.0,
"query_count": 0, "write_count": 0, "stats_count": 1,
"last_query_ms": 0, "max_query_ms": 0,
"stats_sweep_ms": 80.72, "seed_ms": 62.83
}
}
(The telemetry values above are illustrative — they change every run.)
The server's own startup log on stderr follows the same split for a scaled run:
seed.scale_components=2000 # fact — reproducible for a fixed seed
seed.scale_tasks=1500 # fact
# seed.elapsed=63ms # telemetry — varies, never pinned
# seed.node_rate=59896 nodes/s # telemetry
# mem.heap_alloc=9.46 MiB # telemetry
Run a query
POST /query takes {"query": "<cypher>", "params": {<optional>}} and returns
{"columns": [...], "rows": [{...}]}. Because some queries below contain Cypher
string literals ('done'), the examples post the body via a quoted heredoc so
the shell leaves it untouched:
curl -s localhost:8080/query -d @- <<'JSON'
{"query":"MATCH (u:Developer) RETURN u.key AS dev ORDER BY dev"}
JSON
A write is durable the moment the response returns:
curl -s localhost:8080/query -d @- <<'JSON'
{"query":"CREATE (d:Developer:People {key:'dev:zoe', name:'Zoe'}) RETURN d.key AS key"}
JSON
{"columns":["key"],"rows":[{"key":"dev:zoe"}]}
The maintenance-query catalogue
These nine queries are the point of the example: each is a real software-
maintenance question answered over the multi-layer graph. Outputs below are the
actual responses against the seeded fixture.
Q1 — Change impact / blast radius
If comp:platform/config.go changes, which components are affected, and which
tasks and developers must be told?
MATCH (x:Component {key:$k})
MATCH (affected:Component)-[:DEPENDS_ON*0..]->(x)
OPTIONAL MATCH (dev:Developer)-[:ASSIGNED_TO]->(t:Task)-[:TOUCHES]->(affected)
RETURN affected.key AS component,
collect(DISTINCT t.key) AS impactedTasks,
collect(DISTINCT dev.key) AS impactedDevelopers
ORDER BY component
curl -s localhost:8080/query -d @- <<'JSON'
{"query":"MATCH (x:Component {key:$k}) MATCH (affected:Component)-[:DEPENDS_ON*0..]->(x) OPTIONAL MATCH (dev:Developer)-[:ASSIGNED_TO]->(t:Task)-[:TOUCHES]->(affected) RETURN affected.key AS component, collect(DISTINCT t.key) AS impactedTasks, collect(DISTINCT dev.key) AS impactedDevelopers ORDER BY component","params":{"k":"comp:platform/config.go"}}
JSON
config.go is the most foundational file, so all 12 components come back.
First rows:
{"component":"comp:api/handlers.go","impactedTasks":["task:WS-8"],"impactedDevelopers":["dev:alice"]}
{"component":"comp:catalog/repository.go","impactedTasks":["task:WS-4"],"impactedDevelopers":["dev:bob","dev:alice"]}
Q2 — Code ownership ("who knows this code")
MATCH (dev:Developer)-[:ASSIGNED_TO]->(:Task)-[r:TOUCHES]->(c:Component {key:$k})
RETURN dev.key AS developer, count(r) AS touches, sum(r.churn) AS totalChurn
ORDER BY totalChurn DESC
{"columns":["developer","touches","totalChurn"],
"rows":[{"developer":"dev:erin","touches":1,"totalChurn":120},
{"developer":"dev:carol","touches":1,"totalChurn":25}]}
Q3 — Bus-factor risk sweep
Which components have been touched by exactly one developer?
MATCH (dev:Developer)-[:ASSIGNED_TO]->(:Task)-[:TOUCHES]->(c:Component)
WITH c, count(DISTINCT dev) AS busFactor, collect(DISTINCT dev.key) AS owners
WHERE busFactor = 1
RETURN c.key AS component, busFactor, owners
ORDER BY component
Nine components are owned by a single developer, e.g.:
{"component":"comp:orders/service.go","busFactor":1,"owners":["dev:bob"]}
{"component":"comp:payments/gateway.go","busFactor":1,"owners":["dev:frank"]}
{"component":"comp:platform/auth.go","busFactor":1,"owners":["dev:dave"]}
Q4 — A developer's workload
What is assigned to Alice and not yet done?
MATCH (d:Developer {key:$dev})-[a:ASSIGNED_TO]->(t:Task)
WHERE t.status <> 'done' AND a.state <> 'done'
RETURN t.key AS task, t.title AS title, t.status AS status, t.priority AS priority, a.role AS role
ORDER BY priority DESC
{"rows":[
{"task":"task:WS-9","title":"Add product search to catalog","status":"in_progress","priority":7,"role":"author"},
{"task":"task:WS-13","title":"Add catalog caching layer","status":"todo","priority":6,"role":"author"}]}
Q5 — What is blocked, and why (transitive chain)
MATCH path = (root:Task)-[:BLOCKS*1..]->(t:Task {key:$task})
WHERE root.status <> 'done'
RETURN [n IN nodes(path) | n.key] AS chain
ORDER BY length(path) DESC
{"rows":[
{"chain":["task:WS-14","task:WS-10","task:WS-12"]},
{"chain":["task:WS-10","task:WS-12"]}]}
Q6 — Most-depended-upon component
MATCH (c:Component)<-[:DEPENDS_ON]-(dependent:Component)
RETURN c.key AS component, count(dependent) AS inDegree
ORDER BY inDegree DESC
LIMIT 10
{"rows":[
{"component":"comp:platform/config.go","inDegree":3},
{"component":"comp:catalog/service.go","inDegree":2},
{"component":"comp:platform/logging.go","inDegree":2}]}
Q7 — Dependency cycles
MATCH (c:Component)-[:DEPENDS_ON*1..]->(c)
RETURN DISTINCT c.key AS componentOnCycle
ORDER BY componentOnCycle
{"rows":[
{"componentOnCycle":"comp:orders/service.go"},
{"componentOnCycle":"comp:payments/service.go"}]}
Q8 — Component history
MATCH (dev:Developer)-[:ASSIGNED_TO]->(t:Task)-[r:TOUCHES]->(c:Component {key:$k})
RETURN dev.key AS developer, t.key AS task,
r.change_type AS change, r.churn AS churn, r.at AS at
ORDER BY at DESC
{"rows":[
{"developer":"dev:carol","task":"task:WS-2","change":"modify","churn":25,"at":"2026-01-15T16:30:00Z"},
{"developer":"dev:erin","task":"task:WS-1","change":"add","churn":120,"at":"2026-01-08T16:00:00Z"}]}
Q9 — Layer roots (multi-label anchor selection)
Which repositories anchor the code layer?
MATCH (r:Code:Repository)
RETURN r.key AS repository, r.name AS name
ORDER BY repository
Every node carries both a layer label (Code) and a type label
(Repository), so this is a genuine multi-label pattern. The layer is written
first but is far broader than the type: the Code layer is dominated by
:Component files, while :Repository is the handful of roots. GoGraph's
planner re-anchors the scan onto the smaller label — see
Multi-label anchor selection
below for the measured effect.
Schema, constraints and query plans
At store initialisation the server declares its schema as Cypher DDL,
idempotently (so re-opening a populated store is a no-op):
CREATE CONSTRAINT component_key_unique IF NOT EXISTS
FOR (c:Component) REQUIRE c.key IS UNIQUE
CREATE INDEX IF NOT EXISTS developer_key_idx
FOR (d:Developer) ON (d.key)
The UNIQUE constraint guarantees Component.key integrity; the secondary
index accelerates Developer.key equality lookups. Because the fixture is
loaded through the storage layer directly (which does not feed the engine's
index change fan-out), the DDL is issued after the fixture, so each backing
index is backfilled from the seeded graph; every later write goes through the
engine (POST /query) and maintains the indexes incrementally.
Inspect the schema
curl -s localhost:8080/schema
{
"labels": ["Code","Component","Developer","Module","People","Repository",
"Sprint","Task","Team","Work","WorkflowState"],
"relationship_types": ["ASSIGNED_TO","BLOCKS","CONTAINS","DEPENDS_ON",
"HAS_STATE","IN_SPRINT","MEMBER_OF","NEXT",
"SUBTASK_OF","TOUCHES"],
"property_keys": ["active","at","..."],
"indexes": [{"name":"__uniq__Component.key","type":"hash"},
{"name":"developer_key_idx","type":"hash"}],
"constraints": [{"name":"component_key_unique","type":"UNIQUE",
"label":"Component","property":"key"}]
}
The sets are sorted so the output is stable and pinnable as facts. A UNIQUE
constraint's backing hash index is reported under its reserved
__uniq__<Label>.<prop> name alongside the plain index.
Constraint enforcement
Re-creating an existing Component.key violates the UNIQUE constraint. The
write is rejected with 409 Conflict and rolled back atomically — the graph is
unchanged:
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/query -d @- <<'JSON'
{"query":"CREATE (c:Component:Code {key:'comp:platform/config.go'})"}
JSON
409
{"error":"exec: constraint violation: UNIQUE constraint on (Component).key: value \"comp:platform/config.go\" already exists","kind":"conflict"}
Explain a query plan
POST /explain shows whether the declared schema turns an equality lookup into
an index seek. The Q1/Q6/Q8 filter on Component.key and Q4's filter on
Developer.key are served by a NodeByIndexSeek:
curl -s localhost:8080/explain -d @- <<'JSON'
{"query":"MATCH (c:Component) WHERE c.key = $k RETURN c","params":{"k":"comp:platform/config.go"}}
JSON
{"plan":"ProduceResults\n└─ Projection\n └─ NodeByIndexSeek\n","uses_index_seek":true}
An equality on an un-indexed property falls back to a full label scan
("uses_index_seek":false, plan contains NodeByLabelScan), which is itself
evidence: the difference between the two plans is the difference between an O(1)
seek and an O(N) scan.
Multi-label anchor selection (min-cardinality scan)
Every node carries two labels — a layer (Code / Work / People) and a type
(Repository, Component, Developer, …) — so a layer is a superset of each
of its types. A multi-label pattern that lists the broad layer label first, such
as catalogue query Q9 MATCH (r:Code:Repository) …, is anchored by the IR
translator on the first label (Code) with Repository re-checked as a filter
— a full scan of the whole code layer. Because a label conjunction is
commutative, GoGraph's build-time planner re-anchors the scan on the
smallest-cardinality label, Repository, and re-checks Code as the
residual filter: an identical result multiset, far fewer candidate rows scanned.
After a scaled seed (-scale-components=N) the server prints the evidence on
stderr — the physical plan (scan re-anchored on the smaller label), the two
candidate cardinalities (the rows each plan's scan visits — the db-hit skew),
and a wall-clock and allocation contrast between the default engine and one
built with DisableMinLabelScan:
$ 25_software_house_api -d ./data -scale-components=50000
minlabelscan.anchor=Repository
minlabelscan.anchored_on_smaller_label=true
minlabelscan.layer_label=Code
minlabelscan.layer_scan_rows=52270 # rows the first-label plan would scan
minlabelscan.type_label=Repository
minlabelscan.type_scan_rows=252 # rows the re-anchored plan scans
minlabelscan.result_rows=252
# minlabelscan.plan| ProduceResults
# minlabelscan.plan| └─ Sort
# minlabelscan.plan| └─ Projection
# minlabelscan.plan| └─ Selection
# minlabelscan.plan| └─ NodeByLabelScan [r:Repository]
# minlabelscan.on_query=214µs # optimisation ON (default)
# minlabelscan.off_query=9.263ms # optimisation OFF (DisableMinLabelScan)
# minlabelscan.on_alloc=56.70 KiB
# minlabelscan.off_alloc=469.84 KiB
# minlabelscan.time_speedup=43.3x # telemetry — varies per run and machine
# minlabelscan.alloc_reduction=8.3x
The minlabelscan.* bare lines are deterministic facts (the chosen scan
label and the two cardinalities); the # lines are volatile telemetry
(plan text and the ON/OFF timing and allocation contrast). Scanning 252 rows
instead of 52 270 — the same result multiset — is the whole win.
Persistence — survives a crash
Every committed write is fsynced to the WAL before the response returns, so the
data survives even a hard kill:
go run ./examples/25_software_house_api -d ./data -addr :8080 &
curl -s -XPOST localhost:8080/seed
curl -s localhost:8080/query -d @- <<'JSON'
{"query":"CREATE (d:Developer:People {key:'dev:zoe', name:'Zoe'})"}
JSON
kill -9 %1 # crash: no graceful shutdown, no final snapshot
go run ./examples/25_software_house_api -d ./data -addr :8080 &
curl -s localhost:8080/stats # "Developer": 7 — the seed AND dev:zoe survived
On restart, recovery replays the WAL on top of the last snapshot. A graceful
SIGTERM additionally writes a fresh snapshot to shorten the next replay. Both
paths are covered by cross_process_test.go.
The schema survives too. The CREATE CONSTRAINT / CREATE INDEX statements
are WAL-logged writes, and the shutdown snapshot embeds the constraint set and
index definitions. On open the engine is built with
cypher.NewEngineWithStoreAndSchema, which re-registers both from the recovered
state and re-backfills each backing index — so the UNIQUE constraint still
rejects duplicates and the index seek still fires after a restart. Both
cross_process_test.go restart paths assert this (constraint present in
/schema, and a duplicate Component.key rejected with 409). Using the plain
cypher.NewEngineWithStore here would silently drop enforcement and revert
index seeks to full label scans across a restart.
The data directory holds wal (the log) and snapshot/ (manifest plus the
CSR, labels, properties, mapper, and — once the schema is declared —
constraints.bin and indexdefs.bin images).
Concurrency
The store is opened once and shared by all handlers. Read queries run in
parallel; write queries and the seed are serialised. The store owns a
sync.RWMutex (a shared hold for readers, an exclusive hold for writers) as the
outermost lock because the engine's plan-building phase reads live graph
structures that a concurrent write mutates — see the note in store.go. The
hold is kept across the engine call, the row drain, and Result.Close. The
result is snapshot-isolation reads with serialised writes;
server_concurrency_test.go exercises it under -race.
Close takes the same exclusive hold before releasing the WAL, so it quiesces
any in-flight write rather than closing the WAL underneath a commit that is
about to fsync — which would otherwise leave that write applied in memory but
lost from the WAL. After Close takes the hold it marks the store closed, so a
request that arrives during shutdown is cleanly rejected with 503 instead of
being admitted onto the closing WAL. close_quiesce_test.go proves the
quiesce-and-reject contract under -race.
Evidence it collects
Following the examples standard, this example
is both a demonstration and a source of evidence. It reports the dimensions that
matter for a persistent Cypher service over a graph structure:
- Live heap and bytes-per-element —
GET /stats forces a GC and reports
telemetry.heap_alloc_bytes and telemetry.bytes_per_element (live heap
divided by node+edge count), the structures evidence for the in-memory LPG.
- Per-endpoint latency —
telemetry.last_query_ms, max_query_ms, and
stats_sweep_ms (the sweep the response itself measured), plus request
counters (query_count, write_count, stats_count).
- Seed/build cost —
telemetry.seed_ms and the startup # seed.elapsed /
# seed.node_rate telemetry lines.
- Planner anchor selection — after a scaled seed the startup
minlabelscan.* lines report the multi-label scan's chosen label and its
candidate cardinalities, plus a wall-clock and allocation contrast with the
optimisation disabled (see
Multi-label anchor selection).
When you scale the graph up (-scale-*), watch bytes_per_element settle
(~460 B per element at the per-edge-property scale this example uses) and the
catalogue queries' latency grow — in particular the unbounded variable-length
queries (Q1's DEPENDS_ON*0.., Q7's DEPENDS_ON*1..) become expensive on a
dense dependency graph, which is itself evidence worth observing.
Tests
go test ./examples/25_software_house_api/... # short layer (~5s)
go test -race ./examples/25_software_house_api/... # race detector
Coverage: deterministic seed and the full query catalogue (seed_test.go); the
opt-in synthetic scale — zero-scale equals the bare fixture, pinned
deterministic counts at a fixed seed, structural invariants, and the realism
properties the queries depend on (synth_test.go); every endpoint and error
path including the scaled POST /seed and the GET /stats telemetry block
(server_test.go, synth_test.go); the schema surface — the GET /schema
label/relationship/index/constraint facts, the 409 constraint-violation path
with atomic rollback, POST /explain index-seek vs label-scan, and in-process
schema durability across reopen (schema_test.go); the multi-label
anchor-selection evidence — the scan re-anchored onto the smaller label, the
cardinality skew, and ON/OFF result identity (min_label_scan_test.go); concurrent readers/writers
(server_concurrency_test.go); the Close quiesce-and-reject contract against a
concurrent write (close_quiesce_test.go); in-process reopen
(lifecycle_test.go); and a real process restart under both SIGTERM and
kill -9, each asserting the schema is durable (cross_process_test.go). The
synthetic tests assert only deterministic facts (counts, structure, presence of
telemetry fields) — never volatile latency or heap values.
go.uber.org/goleak guards against goroutine leaks via main_test.go.