25_software_house_api

command
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 3, 2026 License: MIT Imports: 29 Imported by: 0

README

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).

  • 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.
GET /stats Node/edge counts (facts) plus a volatile telemetry object.
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 eight 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"}]}

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 data directory holds wal (the log) and snapshot/ (manifest plus the CSR, labels, properties and mapper 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-elementGET /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 latencytelemetry.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 costtelemetry.seed_ms and the startup # seed.elapsed / # seed.node_rate telemetry lines.

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); 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 (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.

Documentation

Overview

Command 25_software_house_api is a persistent REST WebAPI that demonstrates how to build, query and mutate a multi-layer Labeled Property Graph (LPG) with GoGraph in a production-shaped service. The domain is task management inside a software-house: one graph spans the code base, the work that changes it, and the people who do that work.

The full data model, REST contract and maintenance-query catalogue are specified in SPEC.md alongside this file. This doc comment summarises the runtime surface.

The multi-layer model

A single LPG holds three layers, distinguished by a layer label on every node (each node also carries a type label):

  • Code — Repository, Module, Component, joined by CONTAINS (containment) and DEPENDS_ON (the dependency graph).
  • Work — Task, Sprint, WorkflowState, joined by SUBTASK_OF, NEXT, BLOCKS, HAS_STATE and IN_SPRINT.
  • People — Developer, Team, joined by MEMBER_OF.

Two inter-layer "coupling" edges stitch the layers together and form the spine of every cross-layer question:

(:Developer)-[:ASSIGNED_TO]->(:Task)-[:TOUCHES]->(:Component)

Completed work carries TOUCHES edges (realised history); planned work carries only ASSIGNED_TO {state:'planned'}. See SPEC.md §5.

Endpoints

The server listens on the address given by -addr (default :8080) and exposes four routes. Request and response bodies are JSON.

POST /query
    Run an arbitrary Cypher statement (read or write). Request:
        {"query": "<cypher>", "params": {<optional>}}
    Success (200) returns one object per row keyed by the output
    columns:
        {"columns": ["..."], "rows": [{"col": value, ...}]}
    A write-only statement (no RETURN) returns
    {"columns":[],"rows":[]} after the write is durably committed.

POST /seed
    Idempotently load the fixture. An empty body loads the small
    deterministic hand-authored fixture (46 nodes, 106 edges); supplying
    scale fields grows it with a seeded synthetic layer:
        {"scale_components": N, "scale_tasks": N,
         "scale_developers": N, "scale_seed": S}
    Returns
        {"seeded": <bool>, "status": "ok",
         "scale_components": N, "scale_tasks": N, "scale_developers": N}
    seeded is false when the graph was already populated (the synthetic
    layer commits in the same atomic transaction as the base fixture, so
    it is applied exactly once, alongside the first seed).

GET /stats
    Deterministic FACTS — node counts by type label and edge counts by
    relationship type — plus a separate volatile TELEMETRY object holding
    live Go heap, bytes per stored element, request counters, and recent
    per-endpoint latencies:
        {"nodes": {"Component": N, ...},
         "edges": {"DEPENDS_ON": N, ...},
         "telemetry": {"heap_alloc_bytes": N, "bytes_per_element": F,
                       "query_count": N, "last_query_ms": F, ...}}
    The nodes/edges counts are reproducible for a fixed seed and scale;
    every telemetry field varies per run and per machine. The split is the
    JSON analogue of the "# " telemetry convention the non-server examples
    use (see docs/examples-standard.md).

GET /healthz
    Liveness probe; returns {"status":"ok"} without touching the graph.

Errors use a typed envelope {"error": "<message>", "kind": "<kind>"} with the matching status: 400 (malformed JSON or a Cypher syntax / unsupported-feature error), 405 (wrong method), 413 (body over 1 MiB), 422 (Cypher semantic or bad-parameter error), 500 (runtime), 503 (shutting down).

Persistence and recovery

The data directory given by -d holds <dir>/wal (the append-only, CRC-framed write-ahead log) and <dir>/snapshot/* (a manifest plus the CSR, labels, properties and mapper images). Every committed write is fsynced to the WAL before the commit is acknowledged, so the store is kill -9 safe: a crash with no clean shutdown still recovers every acknowledged write by replaying the WAL on the next start. On startup the server calls recovery.OpenCtx, which loads the snapshot and replays any WAL tail; on graceful shutdown it writes a final snapshot (an optimisation that shortens the next replay) and closes the WAL.

Concurrency and isolation

The store is opened once and shared by every handler. The Cypher engine's read execution is lock-free over an immutable snapshot and write commits are atomic; however, its plan- and filter-building phase reads the live adjacency offsets and interning tables, which a concurrent write mutates. The store therefore serialises access with an RWMutex it owns: read queries take a shared hold and run in parallel, while write queries and the seed take the exclusive hold. The hold is the outermost lock — acquired before any engine call and kept across the row drain and Result.Close — so it cannot invert with the store's internal single-writer mutex. The guarantee delivered is snapshot-isolation reads with serialised writes: a reader never observes a partially-applied write, and writers never overlap.

Close participates in the same serialisation: it takes the exclusive hold before releasing the WAL, so it cannot close the WAL underneath an in-flight write that is about to fsync its commit (which would leave that write applied in memory but lost from the WAL). Once Close has taken the hold it marks the store closed, so any later request is cleanly rejected with 503 rather than admitted onto the closing WAL.

Lifecycle and flags

-d <dir>                data directory holding the WAL and snapshot (required)
-addr <host:port>       HTTP listen address (default ":8080")
-scale-components <n>   extra synthetic :Component nodes to seed at startup
                        (default 0: the small deterministic fixture only)
-scale-tasks <n>        extra synthetic :Task nodes to seed at startup
-scale-developers <n>   extra synthetic :Developer nodes to seed at startup
-scale-seed <s>         RNG seed fixing the synthetic data shape (default 1)

When any -scale-* flag is non-zero the server seeds the synthetic layer once at startup, before serving, and prints the build telemetry on stderr — deterministic facts as bare lines (seed.scale_components=…), volatile telemetry as "# "-prefixed lines (# seed.elapsed=…, # mem.heap_alloc=…). The same scale can also be requested per call via the POST /seed body.

On SIGINT or SIGTERM the server stops accepting connections, lets in-flight requests finish, writes a final snapshot, and closes the WAL, in that order. That ordering lets clients receive their responses; crash-safety does not depend on it, because Close quiesces any straggler write itself before releasing the WAL. The process exits 0 on a clean run, 1 on a runtime failure, and 2 on a usage error.

Example session

# small deterministic default
go run ./examples/25_software_house_api -d /tmp/shop -addr :8080 &
curl -s -XPOST localhost:8080/seed
curl -s localhost:8080/stats
curl -s -XPOST localhost:8080/query \
    -d '{"query":"MATCH (c:Component)<-[:DEPENDS_ON]-(d) RETURN c.key AS component, count(d) AS inDegree ORDER BY inDegree DESC LIMIT 5"}'

# observable-scale run: ~5.7k nodes, ~19k edges, seeded and reproducible
go run ./examples/25_software_house_api -d /tmp/big -addr :8081 \
    -scale-components 2000 -scale-tasks 1500 -scale-developers 80 -scale-seed 7
curl -s localhost:8081/stats   # read the "telemetry" object for heap and latency

See README.md for the full maintenance-query catalogue with sample output and a kill -9 / restart persistence demonstration.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL