Skip to content

Improve traversal and query performance, implement Return, close filter injection - #2

Merged
justintout merged 12 commits into
mainfrom
feature/perf
Sep 14, 2026
Merged

justintout merged 12 commits into
mainfrom
feature/perf

Conversation

@justintout

@justintout justintout commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

Traversal cost now tracks the set of nodes a query reaches instead of graph size. On a 10k-node graph, a 6-hop traversal drops from 2.57s to 2.5ms. Return(), previously a no-op, now projects columns. Filter column names are no longer an SQL injection point. A new harness benchmarks the library against GraphQLite.

Before/after numbers come from 6 alternating runs of the old and new benchmark binaries (all p=0.002):

Benchmark Before After
10k nodes, 1 hop 4.57 ms 14 µs
10k nodes, 3 hops 96 ms 100 µs
10k nodes, 6 hops 2.57 s 2.5 ms
10k nodes, both directions, 3 hops 516 ms 487 µs
Dense 500 nodes, 10 hops 952 ms 9.8 ms
Tree, 1,093 nodes 97 ms 1.9 ms
Label + name match, 10k nodes 1.55 ms 4.6 µs
100-node chain, 1 hop 32 µs 8.8 µs
CreateNode 15.0 µs 11.6 µs
CreateEdge 14.6 µs 7.2 µs
CreateNode, on disk 378 µs 68 µs
Bulk insert, 1000 in a transaction 16.2 ms 6.5 ms

Changes

Migration 2 (schema)

  • Without statistics, SQLite used idx_edges_type for every hop. Each expanded node then scanned all edges of that type.
  • Edge indexes are replaced with covering (source_id, type, target_id) and (target_id, type, source_id) indexes. Prefix-redundant indexes are dropped.
  • node_labels is rebuilt as a WITHOUT ROWID table.
  • nodes(name) gets an index.
  • Tested against an existing version-1 database with data.

Query compiler

  • Each step compiles to a CTE of node IDs, and nodes is joined once for the final set. Before, traversals scanned nodes and ran DISTINCT over the JSON properties.
  • CROSS JOIN fixes join order. Otherwise SQLite flattened chained steps into full edge scans.
  • Both compiles to two indexed SELECTs instead of an OR join.
  • A unary + on the label check makes a name equality drive the query.
  • LIMIT/OFFSET are bound parameters, emitted only when set. Interpolated values grew the per-connection statement cache without bound. A bound LIMIT forces a re-prepare on every run, which is why it is omitted when unused.
  • The final set is joined as a DISTINCT subquery instead of IN. IN builds a Bloom filter, which costs an mmap syscall per query under modernc.

Return()

  • Selects only the named columns. Property paths are extracted in SQL, so rows skip decoding the whole JSON document.
  • On a 6-hop traversal reaching ~700 nodes with 10-key properties:
Returned Time Allocations
Everything 6.3 ms 63k
name only 1.8 ms 2k
name + 2 properties 4.2 ms 16k

SQL injection

  • Where and WhereRel wrote the field name into SQL unchecked. They now accept only id, name, created_at, updated_at, and properties.
  • JSON paths in WhereJSON, WhereRelJSON, and Return are bound parameters. The measured cost is the same as literal paths.

Writes and connections

  • Timestamps are generated in Go and bound into plain INSERT/UPDATE statements. That replaces the follow-up SELECT, and avoids RETURNING, which buffers rows in a temp table that spills to disk in large transactions.
  • Savepoints are used only outside a transaction. Inside one, they made SQLite journal every touched page to a temp file.
  • Together these cut a 100k-node / 500k-edge load in one Tx from 18.2 s to 5.3 s.
  • The compiler builds SQL without fmt: simple matches ~12% faster, 1-hop traversals 10–18% faster.
  • Connections open in WAL mode.

Benchmarks

  • Added large-graph traversal, file-backed write, and Return benchmarks.
  • Added a test that checks every traversal compilation path, in all directions, against walks computed in Go on a random graph with cycles.

Comparison with GraphQLite

bench/compare is a separate module, since it needs CGo. It loads the same graphs into three engines and fails if they disagree on any result size:

  • ours: this library.
  • our SQL on C SQLite: the SQL this library generates, run through C SQLite on the same database file.
  • GraphQLite 0.8.0: variable-length Cypher queries.

Medians of 6 runs, Apple M3 Max:

Query ours our SQL on C SQLite GraphQLite
10k random, 1 hop 11.0 µs 5.2 µs 9.4 µs
10k random, 3 hops 63 µs 34 µs 79 µs
10k random, 1..5 hops 458 µs 278 µs 778 µs
100k power-law, 1 hop 10.0 µs 4.4 µs 7.7 µs
100k power-law, 3 hops 50 µs 26 µs 66 µs
100k power-law, 1..5 hops 540 µs 332 µs 1.66 ms
Hub, in-edges, 1 hop (10k nodes) 7.9 ms 5.5 ms 26.8 ms
Hub, in-edges, 2 hops (45k nodes) 127 ms 83 ms 196 ms
Load 100k nodes / 500k edges 4.8 s – 2.6 s
  • Traversals: we're faster at 2+ hops and on the hub queries, by 1.1x to 3.4x. GraphQLite is ~20% faster on single hops.
  • SQLite engine cost: our SQL on C SQLite beats GraphQLite on every query. That puts most of the remaining gap in the pure-Go SQLite engine rather than the schema or queries.
  • Loading: GraphQLite's bulk path, which writes table rows directly, is ~1.9x faster than our per-row CreateNode/CreateEdge.

Behavior changes

  • Breaking: Where and WhereRel return an error for columns other than id, name, created_at, updated_at, and properties.
  • Return(): fields not returned are left zero. Properties is nil unless properties or a property path is returned.
  • Fixed: WhereRel on an intermediate multi-hop step was silently ignored.
  • Fixed: Offset without Limit produced invalid SQL.
  • Count() on traversals ignores Limit/Offset, matching simple matches.
  • On-disk databases get -wal and -shm files.
  • A Tx method that returns an error may leave part of its writes in the transaction (for example, a node without its labels). Roll back after any error. This is documented on Tx.
  • created_at/updated_at from the API come from the Go clock, in the same format as the schema default.

Test plan

  • go vet ./... and go test ./... pass.
  • New tests: WhereRel on an intermediate step, Both direction, offset-only queries, Return projection, hostile column names and JSON paths.
  • EXPLAIN QUERY PLAN checked on a populated graph for single-hop, recursive, chained, filtered, and JSON-filter queries: no table scans, Bloom filters, or automatic indexes.
  • A version-1 database migrates, then passes label, traversal, and cascade-delete checks.
  • All three engines in bench/compare return identical result sizes for every query.

Follow-ups not included

  • Bulk load: a batch-insert API to close the remaining ~1.9x load gap.
  • Breadth-first traversal: 4.8x faster on dense graphs, but up to 7x slower on chains, and hop count doesn't predict which case applies. Planned as a separate, stacked PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C

justintout and others added 8 commits September 13, 2026 19:48
Every traversal step used idx_edges_type, scanning all edges of a type
per expanded node, and the final SELECT scanned the nodes table. On a
10k-node graph a 6-hop query took 2.5s; it now takes 2.5ms.

Migration 2 replaces the edge indexes with covering (source_id, type,
target_id) and (target_id, type, source_id) indexes, rebuilds
node_labels as WITHOUT ROWID, and indexes nodes.name.

The compiler emits one CTE of node IDs per step, joins nodes once for
the final set, forces join order with CROSS JOIN, and splits Both into
two indexed SELECTs. LIMIT/OFFSET are bound, and emitted only when set,
so pagination does not grow the per-connection statement cache.

Fixes WhereRel on intermediate multi-hop steps being ignored and Offset
without Limit producing invalid SQL. Count now ignores Limit/Offset for
traversals, matching simple matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
Drops the follow-up SELECT that read back IDs and timestamps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
File-backed autocommit CreateNode drops from ~380us to ~76us, and
readers no longer block on the writer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
Return was accepted but ignored. It now selects only the named columns
and extracts property paths in SQL, skipping the full JSON decode. On a
6-hop traversal reaching ~700 nodes with 10-key properties, returning
only name drops from 6.3ms to 1.8ms and 63k to 2k allocations.

Where and WhereRel wrote the field name into SQL unchecked; they now
accept only node columns. JSON paths in WhereJSON, WhereRelJSON, and
Return are bound parameters instead of interpolated literals, which
measures the same per execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
A separate module loads the same generated graphs into go-sqlite-graph,
GraphQLite (Cypher via its SQLite extension), and go-sqlite-graph's own
SQL on C SQLite, and fails if any engine disagrees on a result size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
@justintout justintout changed the title Improve traversal and query performance Improve traversal and query performance, implement Return, close filter injection Sep 14, 2026
justintout and others added 3 commits September 13, 2026 21:14
Both forced SQLite to write the statement sub-journal to a temp file on
large transactions: RETURNING buffers its rows in a temp table, and a
savepoint journals every page it touches. Loading 100k nodes and 500k
edges in one Tx drops from 18.2s to 5.3s.

Timestamps are now generated in Go and bound, in the schema default's
format. Savepoints are still used for multi-statement writes outside a
transaction; inside a Tx, a failed method may leave partial writes, and
the caller rolls back.

CreateNode -32%, CreateEdge -35%, BulkInsertTx/batch=1000 -62%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
fmt allocated for each formatted value while compiling. Writing strings
directly makes simple matches ~12% faster and single-hop traversals
10-18% faster; heavy traversals are unchanged.

The new test compares every traversal compilation path, in all three
directions, against walks computed in Go on a random graph with cycles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
@justintout justintout mentioned this pull request Sep 14, 2026
5 tasks done
A multi-hop step deduplicates on (node, depth), so a node reachable at
several depths is expanded at each. BreadthFirst compiles the step to
one CTE per hop level holding nodes first reached at that level, so each
node is expanded once. Results are identical.

Each level costs a fixed ~20us, so which mode wins depends on how much
the walk revisits nodes, which the query cannot predict. It is opt-in:
10 hops on a dense 500-node graph drops from 9.5ms to 2.0ms, and on a
100-node chain rises from 40us to 243us. It requires minHops of 1.


Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@justintout
justintout merged commit 65ce6ca into main Sep 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant