Skip to content

Graph fixes, batch edge writes, graph metrics and read-path speed-ups - #443

Merged
farhan-syah merged 24 commits into
mainfrom
fix/strict-filter-sort-index
Nov 3, 2026
Merged

farhan-syah merged 24 commits into
mainfrom
fix/strict-filter-sort-index

Conversation

@farhan-syah

@farhan-syah farhan-syah commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Closes #436. Closes #437. Closes #369. Closes #372. Closes #376.

Graph fixes, batch edge writes, graph metrics, and read-path speed-ups for indexed, vector and graph requests.

Fixes

Behaviour changes

  • An undirected MATCH returns a self-loop once.
  • An edge batch holds at most 1000 edges. A larger batch fails with 54000.
  • An edge write needs the Write grant on the collection. RLS applies per edge.

Breaking for Lite

  • IndexLookup and SurrogateBitmapScan carry a system_time field.
  • MetaOp::RefreshGraphCount needs a match arm.
  • The NodeDbGraph trait holds the client graph methods.
  • Direction::in_pass_keeps replaces the per-site direction checks.

Speed

One VM, one agent-memory data set, default config. Per-request median in ms. Measured before the rebase onto the snapshot-isolation change.

Request Before After After, indexed agent_id
Indexed document read 2.0 1.92 2.43
Filtered vector search A 3.7 3.34 3.11
Filtered vector search B 2.1 1.78 1.62
2-hop graph traversal 2.64 2.43 2.39
  • Sealed vector index with agent_id indexed: search A falls from 11.5 to 3.15 ms, search B from 4.6 to 1.5 ms.
  • The indexed document read gets slower with the agent_id index. The planner reads the wide index first.
  • 2-hop traversal: core wakes per request fall from 129 to 54.

Tests

  • Full suite, --retries 0: stage 1 24202/24202, stage 2 533/533.

A submit after an idle period now mints an epoch at once instead of
waiting out the rest of the epoch period. A submit within one period of
the last tick still waits for the regular tick, so a burst fills a
single epoch.
The group disk writer notifies the Raft loop after each durable batch.
The loop then counts the new durable entries, dispatches the resulting
messages, and applies newly committed entries immediately, instead of
waiting for the next tick.
Index lookups now carry ORDER BY sort keys and the IndexedFetch handler
orders the matched rows before offset and limit apply, so sorted
queries no longer fall back to a full scan. Every equality conjunct is
a lookup candidate, so a leading equality on an unindexed column no
longer hides an indexed one. Computed SELECT items combined with a sort
still scan.
- Graph neighbor reads gathered from every core now take a node edges
  from its home core only, so a cross-shard edge is returned once.
- MATCH ... IN <collection> reads a collection that has no catalog row,
  since edge writes create it implicitly. A soft-dropped collection stays
  hidden.
- A bare SEARCH ... USING VECTOR as the list of an IN predicate selects
  the compared column instead of *.
- Search hits used as a hash join input resolve their body and primary
  key before the join reads them.
- Correct the working-memory KV example in the agent memory guide.
The poller slept on a timer between passes, so a response waited for
the next wake. Producers now run an optional hook after each push, and
the dispatcher uses it to signal the poller, which waits on that signal
with a timeout and routes a response as soon as it arrives. Pruning
closed LSNs returns early when no range sits at or below the floor.
A submit now ticks at once whenever every epoch batch the leader
proposed has been applied. Submits that arrive while an epoch is in
flight queue and are taken as one epoch after its apply, so a burst
batches as in group commit. The scheduler driver registers a wake only
when a deferred request or cut hold arose during the pass.
A posting row now holds (term_freq, doc_len, positions), so scoring a
term reads its postings and document lengths with one table read. The
backend trait gains a postings-with-lengths read whose default falls
back to reading lengths separately. A document with no recorded length
reads back with its term frequency.
With a residual filter, each core now keeps its passing hits cut to
top_k, and the Control Plane applies the same filter again to the
merged hits. The window widens by the observed pass rate instead of
doubling, and a hit already ranked keeps its body.
Only a statement that can open with COPY, INSERT, or UPSERT is
tokenized for the routing checks, so the cost of a plain query no
longer grows with its length.
Record each sequenced transaction's participant vShards when its
epoch batch applies, and send the verdict signal to those schedulers
instead of every locally registered one. A transaction with no seeded
participants still reaches all of them.
Each RaftNode publishes its term and term-start index at every role
change through a LeaderMark. MultiRaft exposes the marks as a
LeaderBoard, and the Calvin scheduler reads leadership from it each
pass instead of locking MultiRaft.
A WHERE with equalities on several secondary-indexed fields now drives
the lookup from one index and carries the others as index probes. The
executor keeps only the ids every index holds before it decodes a row
body.

A vector search with an equality on an indexed field records it as an
index prefilter. The index lookup runs first and its surrogates gate the
candidate set, so the search ranks only matching rows instead of
widening the window until enough pass a residual filter.

Fix index_lookup capping a value at 1000 entries and scanning past the
value prefix, which dropped rows of a value held by many rows.
GRAPH RAG FUSION previously returned a single text column holding the
JSON result. Add a GraphRag plan kind and a shaper that emits one row
per fused hit with node_id, rrf_score, vector_rank, vector_distance and
hop_distance columns, in rank order.

Run metadata is surfaced as a pgwire NOTICE when the memory budget
stopped the walk early or the walk reached nodes without an identity
binding. Update the graph docs and the wire and cluster tests to read
the new row shape.
The group disk writer applied every staged write in its own redb
transaction, so a drained run paid one commit per write. Move the
per-write bodies into a shared LogOp applier and add
RedbLogStorage::commit_ops, which applies a run of ops in order inside
one transaction. The writer now commits each drained run once. The
single-write LogStorage methods route through the same applier.
GRAPH INSERT EDGE now accepts a list of edges. A clause the current
edge already has starts the next edge, and each edge carries its own
PROPERTIES, VALID_TIME and UNTIL. An edge missing FROM, TO or TYPE
refuses the whole statement.

The statement binds its endpoints in one exchange and writes every edge
as one unit, which is one Calvin transaction when the edges home on more
than one vShard. BatchEdge carries the edge properties so the batch
path writes them. The command tag reports the number of edges written.
A sorted indexed fetch over a strict collection converted every
candidate to a full msgpack row, one value per column, including wide
ones such as embeddings. The sort reads only the columns its keys name,
so candidates now sort as rows holding just those columns, and only the
rows the sort keeps convert in full.

Residual filter matching on strict rows also decodes only the fields
the filters reference. Both paths share new helpers that list the
columns an expression or filter set reads, and a partial Binary Tuple
decoder that falls back to the full decode for tuples written under an
older schema version.
…t windows

A filtered vector search decodes only the strict-row columns the
residual filter reads for each candidate, so a candidate the filter
drops never pays for its other columns. Hits that pass the filter then
attach whole bodies. Transactions keep whole bodies for the Control
Plane to filter after the staged merge.

When the index scans flat (no sealed HNSW segment, no IVF), the first
window asks for more candidates per wanted hit, since a flat scan ranks
every vector regardless of the window width.
- Sparse filter on a sealed segment: the search scores the filter's
  members in that segment exactly. It skips the filtered beam search
  when members^2 <= ef * len * m0. Deleted members are skipped.
- Tombstone count: the HNSW index keeps a count of deleted nodes. Every
  delete and undelete updates it, so a live count reads no node.
- First filtered window: it widens to the search beam, so the beam is
  not ranked twice.
Check that a statement mentions the route keyword before running the
tokenizer, whose cost grows with statement length.
Read the plain index through a dedicated equality probe and build the
prefilter bitmap from the matching keys, without encoding an id list
between the index and the bitmap.
Fan a graph hop out to the cores owning the frontier nodes instead of
every core, rank GraphRAG expansions from the distance map alone, and
merge read versions by insertion.
Graph edge inserts and deletes now run as one unit. A GRAPH INSERT EDGE
or DELETE EDGE statement takes several edges, and the native and remote
clients send them as one EdgePutBatch or EdgeDeleteBatch request. The
batch passes write admission and authorization as a whole.

- Graph node and edge gauges refresh from a per-core count on a
  background loop and report through system metrics and Prometheus.
- Transaction reads record a read tree, so nested reads conflict
  correctly. Graph reads in a transaction see the transaction's staged
  edges.
- Variable-length named pattern expansion and edge-store queries read
  the temporal cut they are given.
- Index lookups and array surrogate scans carry a system-time cut. A
  vector prefilter reads the same cut as the search it feeds.
- The client trait splits its graph methods into a separate definition
  and dispatch module.
- Docs describe the batch syntax and the graph gauges.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment