Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Engine Layers — Schema, Data, Statistics, Cache

A database is not one thing; it is four things layered on top of each other. This chapter names the four layers Samyama’s engine works with, explains what each one is for, and maps them to what already exists in the codebase and what is planned.

The rest of Part III (Query Engine, Query Optimization, Persistence, Managing State, etc.) walks each layer in detail. This chapter is the map.


The four layers

graph TB
    L3["<b>Layer 3 — Cache</b><br/>parameterised-query result cache<br/><i>hash(query, params) → rows, TTL</i>"]
    L2["<b>Layer 2 — Statistics</b><br/>cardinalities, distributions, histograms<br/><i>drives plan selection</i>"]
    L1["<b>Layer 1 — Data</b><br/>nodes, edges, properties, labels<br/><i>the graph itself</i>"]
    L0["<b>Layer 0 — Schema / Metadata</b><br/>labels, edge types, property keys, indexes<br/><i>what the shape of the data is</i>"]

    L3 --> L2 --> L1 --> L0

    style L0 fill:#4a9eff,stroke:#333,color:#fff
    style L1 fill:#51cf66,stroke:#333,color:#fff
    style L2 fill:#ffd43b,stroke:#333
    style L3 fill:#b197fc,stroke:#333,color:#fff

Read the arrows as “depends on”. A cache entry (Layer 3) references data (Layer 1); statistics (Layer 2) summarise the data; the data is described by the schema (Layer 0).

Each layer changes at its own pace, and each has its own API surface.

LayerChanges at speed of…Analogue in PostgresAnalogue in Neo4j
0 — Schemaslow (create/alter DDL)information_schema.*db.schema.*
1 — Datafast (every write)table rowsnodes and relationships
2 — Statisticsperiodic (ANALYZE, or after N writes)pg_statisticdb.stats.*
3 — Cacheshort-lived (per query, TTL)no first-class equivalent (external, e.g. pgpool-II query cache)no first-class equivalent (external caching layer / APOC)

Layer 0 — Schema / Metadata

What it is. The set of labels, edge types, property keys, and indexes that describe the shape of the data — not the data itself. If someone asks “what kinds of things does this graph hold?”, the answer lives at Layer 0.

Why it needs a first-class API. Every mature relational database has an information_schema — you can ask “how many tables?”, “what columns does this table have?”, “what indexes exist?” without touching a single row. That capability is what tools like IDEs, migration frameworks, MCP tool-generators, and query planners depend on. A graph database needs the same.

Graph schema is different from relational schema in one important way: it is fluid. In Postgres you ALTER TABLE before you can insert into a new column. In a property graph, a CREATE (:NewLabel {new_property: 42}) is legal without any prior DDL — the label and the property key spring into existence with the write. So the schema changes, just more slowly than the data does. Layer 0 has to track that fluid evolution.

Where it lives in samyama-graph today: src/graph/catalog.rs (GraphCatalog) holds distinct labels, edge types, and per-label/per-type counts derived from the data. It is rebuilt on bulk load and on snapshot import (samyama-graph#340, #8).

GraphCatalog serves two layers. The schema entries — the set of labels and edge types — are Layer-0 metadata. The per-label / per-edge-type counts on the same struct are the first pieces of Layer 2 (statistics), consumed by the cost model. Both layers read from the same catalog because the shape and the cardinality of the data live naturally in the same place; the layers are distinguished by what is read, not by where it is stored. Layer 2 below adds histograms and other summaries on top.

What ships today at the Cypher level: CALL db.schema.visualization() — returns a schema-shape summary usable by tools and UIs.

Gaps to fill (Layer-0 backlog):

  • Sister procedures beyond visualizationCALL db.schema.labels(), db.schema.edgeTypes(), db.schema.propertyKeys(:Label), and their multi-tenant siblings. Today the rest of this data is only reachable via bespoke HTTP endpoints, not via Cypher.
  • Property-type inference (e.g. “Trial.phase is a string enum of {NA, Phase1, …}”) — the MCP tool generator in sdk/python/samyama_mcp re-derives this on every start; it should live in the catalog.
  • Access control at the schema level (RBAC) — deferred, but the API surface for it belongs on Layer 0.

Layer 1 — Data

What it is. The nodes, edges, and their properties. This is what you read and write with Cypher.

Where it lives: src/graph/GraphStore, Node, Edge, PropertyValue, adjacency lists, columnar property store (ADR-021). Nothing else in the engine holds authoritative data; every other layer either describes it (Layer 0), summarises it (Layer 2), or memoises answers about it (Layer 3).

How it is persisted: RocksDB + WAL when persistence is enabled; snapshots via .sgsnap format for point-in-time export/import. See Persistence at Scale and Managing State (MVCC & Memory) for the mechanics.

What Layer 1 is not: it is not the place for materialised aggregates, statistics, or cached query results. Those all live above. Keeping Layer 1 authoritative and slim is what makes rebuilding everything above it well-defined.


Layer 2 — Statistics

What it is. Summary numbers about Layer 1 that the query planner reads to choose a plan. Row counts per label, edge-type frequencies, in/out-degree distributions, property histograms, distinct-value estimates.

Why they matter. Consider a two-way join between a table (or subgraph) with 100 million rows and one with 100 rows. If the planner knows the smaller side is small, it will hash it and probe the big one — millisecond query. If it treats both sides as opaque, it might scan the big side once per row of the small side — hours. This decision is entirely on the back of statistics. Without them, a cost-based planner has nothing to be cost-based about.

Where it lives in samyama-graph today:

  • Storage: src/graph/catalog.rs (GraphCatalog) — the same struct that carries Layer 0’s schema also carries the cardinality counts that this layer consumes (see the callout under Layer 0). This is the “triple statistics” fixed by samyama-graph#340 and #8 on bulk load and snapshot import.
  • Consumer: src/query/executor/cost_model.rs — reads catalog cardinalities in estimate_label_scan, estimate_expand_in, and estimate_expand_out to score candidate plans. See Query Optimization (Explain) and Picasso — Plan-Space Visualisation for the plan-space enumeration and the reduction-factor metric.

Known gap (well-scoped): property histograms are not yet built. Range and inequality predicates (WHERE t.date > '2024-01-01', WHERE n.age > 30) fall back to the default selectivity constants documented in Picasso — Plan-Space Visualisation (0.33 for range, 0.1 for inequality). Because these are constants, the Picasso parameter-sweep heatmap for such a predicate is uniformly flat — the estimated cost does not respond to the parameter value. This is the QP-06 item on the query-planner backlog and it is the single biggest opportunity to improve plan quality on filtered scans.

Refresh cadence: today Layer 2 is rebuilt at bulk load / snapshot import time (#340, #8). It is not yet incrementally maintained during small writes — a background “analyze” pass is the natural next step once histograms land, so the two changes are usually planned together.


Layer 3 — Cache

What it is. Memoised results of previously-executed queries, keyed by the query text plus its parameter values. On a cache hit, the engine returns the stored rows without re-planning or re-executing.

Why not another in-memory cache layer? Samyama is already an in-memory database. Stacking a second in-memory cache (Redis-style) on top of an in-memory DB just doubles the RAM footprint for the same data with no latency win. Instead, Layer 3 lives inside the graph itself — cache entries are ordinary nodes/edges under a system-owned tenant or a reserved subgraph, with their own TTL.

Key discipline: fully-parameterised or nothing. A cache entry is only safe when the query is fully parameterised — the query text and every value it depends on are known. A query with a literal Virat Kohli in it and a query with $name = "Virat Kohli" are the same execution, but only the second one can be safely hashed for cache lookup, because the first collides with any other literal that happens to look identical.

Contract:

  • Key: hash(canonical_query_text, params, tenant, graph).
  • Value: the QueryResult (columns, records, referenced nodes/edges).
  • TTL: every entry has an expiry timestamp. There is no “cache forever.”
  • Eviction: a lightweight sweeper walks expired entries and removes them.
  • Multi-tenant migration: because cache entries live in the graph, they can be moved between shards / tenants based on where memory is cheapest at the moment. This is a future capability of Layer 3, not a shipping one — but the storage shape makes it easy.

Status: Layer 3 (result cache) is a design decision from the Aug-14 discussion; the storage shape above is the target, and no implementation ships yet.

Not to be confused with the compiled-plan cache. The engine already has a PlanCacheEntry at src/query/executor/planner.rs:397 that memoises the chosen plan (index hint, timestamp) per query hash — that’s a Layer-2/3-boundary optimisation and avoids re-planning, but it does not avoid re-executing. Layer 3 as described here is the larger step: memoise the result rows keyed by query + params, so a hit skips execution entirely.


How the four layers cooperate on a query

sequenceDiagram
    participant C as Client
    participant L3 as Layer 3 (Cache)
    participant P as Planner
    participant L2 as Layer 2 (Stats)
    participant L0 as Layer 0 (Schema)
    participant L1 as Layer 1 (Data)

    C->>L3: MATCH (t:Trial {country:'India'}) RETURN t.trial_id
    alt cache hit
        L3-->>C: cached rows
    else cache miss
        L3->>P: parse + plan
        P->>L0: is 'Trial' a real label? 'country' a real property?
        P->>L2: how many Trials? how selective is country='India'?
        L2-->>P: 363K trials, ~10% match
        P-->>P: pick plan (IndexScan + Filter vs full scan)
        P->>L1: execute chosen plan
        L1-->>P: rows
        P-->>L3: store (query, params, rows, TTL)
        L3-->>C: rows
    end

Every layer is queried once per query, in that order. When statistics are missing (Layer 2 gap), the planner picks the wrong plan. When schema is stale (Layer 0 gap), the planner either errors out or falls back to a scan. When cache is absent (Layer 3 not yet built), you pay the full plan + execute cost every time.


Implementation status

LayerComponentStatus
0GraphCatalog (labels, edge types, per-label counts)Implemented (src/graph/catalog.rs)
0Rebuild after bulk load / snapshot importImplemented (samyama-graph#340, #8)
0CALL db.schema.visualization()Implemented (src/query/executor/planner.rs, operator.rs)
0Other db.schema.* procedures (labels, edgeTypes, propertyKeys(:Label))Plannedsamyama-graph-enterprise#534
0Property-type / enum inference in catalogPlanned (today re-derived by MCP generator) — enterprise#535
0Schema-level RBACDeferred (no issue yet — untimed)
1GraphStore, columnar property store (ADR-021)Implemented
1Snapshot export/import (.sgsnap)Implemented
1RocksDB + WAL persistenceImplemented
2Cardinality catalog + planner integrationImplemented — storage in src/graph/catalog.rs (shared with Layer 0), consumed by src/query/executor/cost_model.rs (estimate_label_scan, estimate_expand_in/out)
2Property histograms (range-predicate selectivity)Planned — QP-06enterprise#536
2Incremental analyze refresh under writesPlanned (follows histograms) — enterprise#537
2/3Compiled-plan cache (PlanCacheEntry — memoises index-hint per query hash)Implemented (src/query/executor/planner.rs:397). This is a plan cache, not a result cache — the two rows below are the result-cache work.
3Parameterised-query result cache with TTL (in-graph storage)Plannedenterprise#538
3Multi-tenant cache-entry migrationPlanned (after Layer-3 v1) — enterprise#539

The strongest lever right now is Layer 2 histograms. It is the difference between Picasso’s parameter-sweep heatmap being flat (today) and telling you something real.



Origin

The four-layer framing was set in the Samyama design meeting on 2026-08-14. This chapter captures that framing so subsequent engine chapters can refer to “Layer 2” or “Layer 0” with a shared meaning. As the corresponding implementations land, this chapter will be updated to match — the layer names are contract, the status rows are living.