Repository navigation
Improve traversal and query performance, implement Return, close filter injection - #2
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
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
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
CreateNodeCreateEdgeCreateNode, on diskChanges
Migration 2 (schema)
idx_edges_typefor every hop. Each expanded node then scanned all edges of that type.(source_id, type, target_id)and(target_id, type, source_id)indexes. Prefix-redundant indexes are dropped.node_labelsis rebuilt as a WITHOUT ROWID table.nodes(name)gets an index.Query compiler
nodesis joined once for the final set. Before, traversals scannednodesand ran DISTINCT over the JSON properties.CROSS JOINfixes join order. Otherwise SQLite flattened chained steps into full edge scans.Bothcompiles to two indexed SELECTs instead of an OR join.+on the label check makes a name equality drive the query.IN.INbuilds a Bloom filter, which costs an mmap syscall per query under modernc.Return()nameonlyname+ 2 propertiesSQL injection
WhereandWhereRelwrote the field name into SQL unchecked. They now accept onlyid,name,created_at,updated_at, andproperties.WhereJSON,WhereRelJSON, andReturnare bound parameters. The measured cost is the same as literal paths.Writes and connections
RETURNING, which buffers rows in a temp table that spills to disk in large transactions.Txfrom 18.2 s to 5.3 s.fmt: simple matches ~12% faster, 1-hop traversals 10–18% faster.Benchmarks
Returnbenchmarks.Comparison with GraphQLite
bench/compareis a separate module, since it needs CGo. It loads the same graphs into three engines and fails if they disagree on any result size:Medians of 6 runs, Apple M3 Max:
CreateNode/CreateEdge.Behavior changes
WhereandWhereRelreturn an error for columns other thanid,name,created_at,updated_at, andproperties.Return(): fields not returned are left zero.Propertiesis nil unlesspropertiesor a property path is returned.WhereRelon an intermediate multi-hop step was silently ignored.OffsetwithoutLimitproduced invalid SQL.Count()on traversals ignoresLimit/Offset, matching simple matches.-waland-shmfiles.Txmethod 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 onTx.created_at/updated_atfrom the API come from the Go clock, in the same format as the schema default.Test plan
go vet ./...andgo test ./...pass.WhereRelon an intermediate step,Bothdirection, offset-only queries,Returnprojection, hostile column names and JSON paths.EXPLAIN QUERY PLANchecked on a populated graph for single-hop, recursive, chained, filtered, and JSON-filter queries: no table scans, Bloom filters, or automatic indexes.bench/comparereturn identical result sizes for every query.Follow-ups not included
🤖 Generated with Claude Code
https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C