Workload Statistics

A graph instance keeps statistics of what was done with it: which scheduled jobs ran and how long they took, how long writers held the graph's lock, which queries ran, and which properties they used and how. They describe the instance, not the data: they are not versioned with the graph, never written to the journal, and never part of a commit, so they do not move with a rollback and are not replicated.

Levels

LevelWhat is kept
offnothing
memoryin memory, gone with the process
persisted (default)in memory, and in the Store's sidecar file WORKLOAD, read back when the store opens

The level is set when a store is opened (StoreOptions::with_workload_level); it is not stored. Switching to off keeps what was collected; only clearing deletes it.

Time buckets

Every series keeps three tiers of buckets, like a round-robin database:

  • one bucket per minute for the last 60 minutes,
  • one per hour for the last 48 hours,
  • one per day for the last 90 days.

Each bucket counts the occurrences, the failed ones, the items they touched, their total and their longest duration. The sidecar keeps all three tiers, so a restart loses none of them; buckets that aged past their window while the process was stopped fall out at the next sample.

Channels

Every sample is counted under the channel of the work that produced it, so the report says who runs what (which queries the AI agents run, which ones the page):

ChannelWork
playgroundqueries run from the browser playground (in the page, or over the dev server)
mcpthe MCP endpoint's tools and the AI Chat's agent
clithe command line: one-shot scripts and the REPL
pythonthe Python binding
jobthe actions of scheduled jobs
hostanything else (an application embedding the library)

Only a user's queries count. The work a front end makes up itself (drawing the Graph panel, the inspector, schema inference, the panels' lists) is technical and not counted. A host declares its channel around the work it does for a request with graphersal::workload::scope(channel, technical, || ..), or once for a thread that serves one front end with set_thread_default(channel, technical); a front end that marks its own work technical runs a user's query in scope(current_channel(), false, ..). Scopes nest, and a job's run is always job.

The graph's lock

The graph has one writer lock: a writer excludes readers and other writers. To see whether it is a problem, three series measure it under the channel of the work that took the lock:

SeriesWhat it measures
write.waita writer asking for the lock until it holds it (contention)
write.holda writer holding the lock (what readers and other writers wait for)
read.waita reader waiting because a writer holds the lock (an uncontended read is not counted)

Each bucket has the count, the total and the longest time, so the mean and the worst wait are at hand. A Store attaches its statistics to its graph; a graph of your own counts after Graph::attach_workload_stats(stats). Work a front end marks as technical (its own drawing and panel queries) is not counted.

Queries

Every run of a traversal on a graph with statistics (GQL included: it runs the same pipeline) counts in the series of its fingerprint: the optimized plan, as .profile() names its steps, with every literal replaced by ?. g.v().has("name", "ann").values("age") and the same query for "bob" are one series, v().has(?, ?).values(?); a list of literals is one ?. Each run adds its result count (with bulk), its duration and whether it failed. A saved query call counts once more as saved:<name> (the call itself; the traversal it returns counts under its plan when it runs). At most 500 query series are kept: a new one beyond that replaces the one run least in the last 90 days. Like pg_stat_statements in PostgreSQL, this answers "which queries run, how often, how long", by channel (which ones the AI agents run).

Properties

The same run also counts, per property, how the plan used it: series (element, label, key, access), each counted once per run with the number of traversers that reached the step using it. The access is one of

AccessSteps
filter_scanhas(key, ..), has(key), has_not(key), has(key, __.to_lower()...), a has folded into v()/e()
filter_indexthe same filters when a property index serves them
readvalues, properties, value_map, element_map, a by(key) of project/select/path/tree
order_groupa by(key) of order, group, group_count, dedup
similaritysimilarity, nearest, embed
write / removeproperty, add_v/add_e properties / remove_property

The element kind and label are what the plan states before the step (v(labels), e(labels), has_label, out_e(labels), add_v(label)); a step that reads every property (value_map(), element_map()) names the key *, and a use inside a child traversal (where(__.out().has("lang"))) has no element, no label and no traverser count. A jpath key counts under its top-level property. These counts answer "which properties are filtered without an index, which are only written, which are read at all": the input of the planned storage advisor.

Memory

The statistics stay bounded (at most 500 query series, at most 60 + 48 + 90 buckets per series, only buckets in which something was counted). Their memory is shown as workload_bytes in g.memory_usage(), in the dev server's and the playground's statistics (Profile ▸ Statistics, Workload statistics), never added to the graph's total_bytes.

The sidecar

A writable store writes WORKLOAD once a minute (the system job stats_flush, see Scheduled Jobs) and when it closes. After an orderly stop nothing is lost; after a crash at most the last minute. The file is best effort: a missing or damaged file means the statistics start anew, never damage, and the store opens as usual. It is never copied into a backup, a fork, a repaired or a converted store: a restored store starts without statistics (an upgrade into a copy keeps them, the copy being the same store). Its byte layout is section 4.4 of the format specification.

A backup opened read-only, a read-only or maintenance-mode store keeps statistics in memory only.

Reading them

Front endHow
playgroundOptimize ▸ Workload: tables with a sparkline per row and a chart per series (Optimize)
REPL (--graph <store>)/workload: one line per series, last 90 days
dev serverGET /api/workload (the JSON below)
MCPtool workload_statistics {max_rows} (at most 50 rows per list by default)
Pythonstore.workload_report() (a dict)
Ruststore.workload_stats().report(now), WorkloadReport::to_property() / to_json() / Display

The JSON form (WorkloadReport::to_json) has the keys level, queries (query), properties (element, label, key, access), locks (lock) and jobs (name, last_run); every row has its channel and the windows last_hour, last_two_days and last_ninety_days, each with count, failed, items, total_micros and max_micros. Rows are sorted by total time over 90 days. A graph outside a store answers level off with empty lists.

History. Asked with a tier (?history=minutes|hours|days on the dev server, the MCP tool's history, Python's workload_report(history="hours"), Rust's report_with_history(now, HistoryTier::Hours)), the report adds history_tier and gives every row its history: the buckets of that tier within its window (the last 60 minutes, 48 hours or 90 days), oldest first, only those in which something was counted, each {start, count, failed, items, total_micros, max_micros} with start in microseconds since the Unix epoch. That is the data of a chart: how often a query ran and how long it took, minute by minute or day by day.

use graphersal::persist::{MemDir, Store, StoreOptions};
use graphersal::workload::{Channel, Sample, SeriesKey, SeriesKind};

let dir = MemDir::named("memory:stats");
let store = Store::create(&dir, StoreOptions::new()).unwrap();
let stats = store.workload_stats();
let now = 1_800_000_000_000_000; // microseconds since the Unix epoch
stats.record(
    SeriesKey::new(SeriesKind::Lock("write.hold".into()), Channel::Cli),
    now,
    Sample::new(10, 250),
);
let report = stats.report(now);
assert_eq!(report.series[0].last_hour.count, 1);
store.close().unwrap(); // writes WORKLOAD