Repository navigation
Description
Problem
A client that shows one node with its relationship counts (a node page, an inspector, a table's relationship columns) needs one count per edge type and direction. GQ accepts a correlated block only as a filter: subquery_predicate requires a comparison (query.pest#L183), and return takes expressions (#L189). So the client sends one query per group:
- A
Personwith 92 relationship groups costs 92 requests to count, 1.0–3.4 s each. - The server saturates under that fan-out: 8 such reads at once take 1.6 s of wall time, 32 take 7.4 s.
- A table page with N relationship columns needs N count queries per page of rows.
Measured against an OmniGraph 0.13.0 server reached over HTTPS from a laptop (/healthz 0.16–0.21 s on a reused connection), with the release CLI (10 ms start-up), 1–3 runs each. The graph: 101 node types, 552 edge types (149 empty), about 4,900 commits, an Artifact table of about 47k rows. Code references are at main d883c49e.
Proposed change
Allow count { … } and exists { … } (and the other block aggregates) as projections, correlated with the outer row:
match { $n: Person $n.@id = $id }
return {
$n,
count { $n knows $m } as knows,
count { $m mentions $n } as mentions,
exists { $n worksAt $c } as employed
}
- One row per outer row, one column per block.
countisI64non-null;existsisBool. - A single-hop, filter-free block is answered from the graph index's degree, as
count { }filters already are (bulk_row_count; docs: queries/index.md#L135). Its cost is then flat in the node's degree. - It also covers a page of nodes (
$n.@id in $ids): all of a table page's relationship columns in one query.
Affected areas
- GQ grammar (
projection). - The typed IR: a block aggregate as a projected expression.
- Lowering: reuse
SubqueryAggregateand its bulk CSR degree path. - Result schema inference and
explain. - The docs' "Correlated blocks" section.
Alternatives considered
- A neighbourhood route returning per-edge-type counts. query: bounded node-and-neighborhood expansion with edge identities #649 asks for a generic expansion route. A route is a second query surface; a projection keeps the semantics in typed query structures (invariant 9) and serves tables too.
- A wildcard traversal grouped by
$e.@type. Members must join the same two node types. On 0.13.0 one node across three edge types took 58 s (performance: a wildcard traversal grouped by$e.@typeexpands in indexed-scan mode and is ~10× slower than its members #1057). - Optional traversals (GQ: optional traversals that preserve unmatched root rows #658) with grouping. These cross-multiply across groups.
- One query per group (today).
Activity
ragnorc commentedon Oct 20, 2026
Summary
A node page needs one count per edge type and direction, on the same row as the node. GQ can use
count { … }andexists { … }only as match filters. Areturnprojection cannot hold those blocks. The client therefore sends one query per edge group. Each query is its own snapshot, and a traversal-plus-count($m)drops every node whose degree is 0.The gap is present on
main85a4613831e5047a0c988ea0ad37768fa7dff4bb. No open pull request adds this projection. This comment does not close the issue.Reproduction
Checkout.
origin/mainis85a4613831e5047a0c988ea0ad37768fa7dff4bb(2026-10-20,fix(server): delete a branch named like a static branch route (#1091)). The binary is the debug CLI from that tree:omnigraph 0.14.0. The graph is a local file store. The schema isPerson,Company,KnowsandMentions(Person -> Person), andWorksAt(Person -> Company). Alice has threeKnowsedges and two distinct neighbors (a parallel edge to Bob). A later append addshubwith 3200Knowsedges and 31 distinct neighbors.Code links in the issue body are at
d883c49e. On this SHA the grammar moved.subquery_predicateis line 186,return_clauseis line 192, andprojectionis line 194 incrates/omnigraph-compiler/src/query/query.pest.Lowering::bulk_row_countis still atcrates/omnigraph/src/engine/lower.rsline 275. The degree sentence indocs/user/queries/index.mdis still lines 134–135.Claim:
returnrejectscount { … }andexists { … }. Reproduced. The error is a parse error, not a type error.Diagnostic
Q001, on the{aftercount. The same error is onexists { $n worksAt $c } as employed, onsum($m.name) { $n knows $m }, and on acount { … }with no alias. Inmatch, acountblock with no comparison fails earlier and more specifically:parse error: expected comp_op.Claim:
subquery_predicaterequires a comparison, andprojectiontakes only an expression. Reproduced.exists { … }is already a match clause and does not need a comparison. It is not an expression, soreturnstill rejects it. Expression operands are calls, properties, variables, and literals. A block is not one of them (query.pestlines 216–218).Exprincrates/omnigraph-compiler/src/query/ast.rshasAggregateforcount($m)and no block form.Claim: the same blocks work as filters. Reproduced. With
$id = "alice":count { $n knows $m } > 0count { $n knows $m } = 2count { $n knows $m } = 3count { $n $e:knows $m } = 3count { $m mentions $n } = 2exists { $n worksAt $c }existsfor dave$n.@id in ["alice","bob","dave","p0","p1","p5"]andcount { $n knows $m } > 0An unbound block counts distinct neighbors. Three
Knowsedges from alice become 2. A bound edge counts edge rows, so the same person is 3. That split is already the filter contract (docs/user/queries/index.mdand the unbound-versus-bound note on issue #764).hubconfirms it at a larger size:count { $n knows $m } = 31keeps the row, andcount($e.@id)over the bound edge returns 3200.Claim: a single-hop, filter-free
count { }uses the graph-index degree. Reproduced for the documented shape.explaindoes not run the query. The datafusion line is the marker lowering selects before execution.AntiJoinMaskExecdetailcount { $n knows $m } > 0and= 2$n count …, bulk=Knows Outcount { $m mentions $n } > 0bulk=Mentions Inexists { $n worksAt $c }bulk=WorksAt Out$n.@id in $idsandcount { $n knows $m } > 0bulk=Knows Out, strategycorrelatedcount { $n <knows> $m } > 0bulk=Knows Bothcount { $n <knows> $m } > 1bulk=count { $n $e:knows $m } = 3bulk=count { $n knows $m $m.name = "Bob" } > 0bulk=count { $n knows $m $m knows $p } > 0bulk=count { $n (knows | mentions) $m } > 0bulk=, expand modeindexed_scancount { $m: Person $n * $m } > 0bulk=, wildcard[Knows out, Mentions out], modeindexed_scanA wildcard with no node bindings fails typecheck:
T5: wildcard traversal requires explicitly declared source and destination node bindings.The user-facing physical
AntiJoinJSON showsstrategy,predicate, andtyped_lefttypeI64. It does not showbulk.bulk=is only on the datafusion operator text (AntiJoinMaskExecincrates/omnigraph/src/engine/operators/anti_join.rs). The docs name a directed edge (docs/user/queries/index.mdlines 134–135). The code also selects the degree path for an undirected existence test (count > 0,bulk=Knows Both). An undirected count that is not an existence test does not. That part of the boundary is wider in code than in the docs. Call the documented directed case reproduced, and the undirected existence case extra.Lowering::bulk_row_count(lower.rslines 275–324) returns one named edge and a direction when the predicate is a row count, the expand is one hop, there is no filter, there is no edge binding, the input is anOuterReference, and the direction is notBothunless the predicate is an existence test.bulk_anti_join_mask(crates/omnigraph/src/engine/graph.rsline 1490) reads the CSR or CSC degree and returns a Boolean keep mask. For a real count it sorts neighbor ids and removes duplicates.SubqueryAggregate(crates/omnigraph/src/engine/operators/subquery_aggregate.rs) folds tagged inner rows only when that mask does not apply. The operator filters. It does not append a column.docs/dev/execution.mdlines 79–82 and 243 describe the same mask.Claim: the workaround is one query per group. Reproduced. For alice, three queries return
knows = 2,mentions = 2, andemployed = 1fromcount($m)orcount($c).count($e.@id)onKnowsreturns 3. A page querywith
aliceanddavereturns only alice. Dave has degree 0, so the required traversal removes that outer row.return { count($m) > 0 as has_knows }works. That is the grouped comparison from #955. It is one Bool for the matched group. It still needs the traversal, so a zero-degree person still produces no row.explaintypes that groupedcount($m)asI64?. The block row-count on anAntiJoinisI64with no?(docs/user/queries/explain.mdline 265: row count is I64 non-null; column aggregates stay nullable).Claim: 92 groups at 1.0–3.4 s, and 8-way / 32-way saturation. Not reproduced. This run did not use the 0.13.0 HTTPS server, the 552-edge graph, or a laptop round trip. Local debug CLI, file store, five runs after one warmup:
$n.slugfor hubcount { $n knows $m } = 31(degree path, 3200 edges, 31 neighbors)count { $n knows $m } > 0return { count($m) }over the same hopreturn { count($e.@id) }over 3200 edge rowsProcess start is about 20 ms, so it dominates this graph. Six processes cost about six times one process. The degree filter and the edge-row count differ by about 7 ms here. These numbers do not confirm or deny the remote 1.0–3.4 s figures.
Root cause
A correlated block is a match clause.
returntypechecks expressions and infers the result schema from those expressions (infer_query_result_schemaincrates/omnigraph-compiler/src/query/typecheck.rs). The planner lowers the block to anAntiJoinwhose predicate keeps or drops the outer row. There is no IR node that places the aggregate value on the output row. The degree fast path computes that value and then throws it away after the comparison.Design
The contract to add is a scalar block value on each outer row.
match. Unboundcount { $n knows $m }is distinct neighbors (alice is 2). Boundcount { $n $e:knows $m }is edge rows (alice is 3). A projection that used the other number would make the filter and the column disagree.countisI64, non-null, and 0 when there is no match. A count past theI64range fails, as the filter already fails.exists { … }isBool, non-null, and true when that count is greater than 0.sum,min,max,avg, and a columncountkeep the nullable block signatures they already have. With no matches,sum,min,max, andavgare null. They do not become 0.Make the block an atomic expression:
count { … },sum(expr) { … },min,max,avg, andexists { … }.projectionthen accepts it with the existingasalias.subquery_predicatestays the match-clause rule and stays ahead of a filter, socount { … } > 0inmatchstays a predicate. A missing comparison inmatchstaysexpected comp_op. Do not addnot { … }as a column in this change.existsis the boolean form.Two unaliased
countcolumns already collide. Keep that error.Fast path. Share the CSR degree read in
bulk_anti_join_mask. Have it return the degree. The filter compares the degree and builds a mask. The projection writes anI64column. One walk, two consumers. Select it only wherebulk_row_countalready returns an edge: one hop, no filter, no edge binding, one named edge, correlated strategy, direction out or in. Forexists, directionBothmay use the existence check the filter already uses (bulk=Knows Bothoncount > 0). A projected undirected count needs an explicit union of the two adjacency lists, and that union must equalcount { $n <knows> $m } = k. Until then, leave the undirected count on the inner tree. The filter for> 1already does that. Wildcard, alternation, a bound edge, a filter, and a second hop stay on the inner tree. They must stay correct. The missing fast path may change cost only. A page$n.@id in $idsis already the correlated bulk shape. The projecting operator walks each outer id, as the mask already does, and it keeps the zeros.Grouping and order. A block value is a scalar on the outer row.
count($m)inreturngroups rows and drops outer rows with no match. Onereturnmust not contain both until a rule names the cardinality. Refuse the mix.ordermay name the alias.distinctincludes the column. A later cut can allowcount { $n knows $m } > 0 as flagas a Bool expression. That is the per-row form of the grouped comparison #955 added. It is not required to close the example in this issue.Explain and schema. The plan node must show the outer variable, the aggregate type, and
bulkwhen lowering selects the degree path. Today an operator must read the datafusion text to seebulk=. Result-schema inference reads the block type:I64non-null,Boolnon-null, or the nullable column aggregate. Profile can keep the current signal: the innerOuterReferenceis skipped when the degree path runs (anti_join_inner_tree_is_skipped_under_the_bulk_check_and_executed_without_it).Why this shape. An expression node is the typed structure the rest of the pipeline already uses. A projection-only grammar would need a second change for
orderand for a Bool comparison. A neighborhood route (#649) returns edge identities for a client that does not know the schema, with a page and a truncation signal. It does not return one integer per known column of a table. Optional match (#658) keeps a root and then multiplies rows by neighbors. A wildcard grouped by$e.@type(#1057) is oneindexed_scan, and the members must share the two endpoint types.What this cut leaves slow on purpose. Wildcard, bound-edge, filtered, and undirected counts stay on the inner tree. Those shapes do not have one CSR degree today. A second degree definition would make the filter and the column disagree. The mix with a grouped
returnaggregate is refused for the same reason: two cardinalities in one clause.Liability. This removes N requests, N commits, and a client join. A zero-degree row becomes visible. The column and the filter are one number, so they cannot drift. This adds a public column type and a cardinality rule. Clients will store both. Explain must show a missing
bulk, or a wide page can look cheap and still scan. The distinct-neighbor rule must stay visible, or a client that wants parallel-edge cardinality will read 2 where the edge table has 3.docs/rfcs/README.mdasks for an RFC when the change is a new query contract. This is that kind of change, because the cardinality and theI64column are hard to take back. The RFC can be short. It records the row rule, the types, the identity with the filter count, the fast-path shapes, and the mixed-aggregate refusal. It does not need to wait for #658 or #649.Tests. Extend the
.gqtcorpus. One case covers several edge types, both directions, degrees 0, 1, and N, one parallel edge, and$n.@id in $ids. Expect the rows, the column types, and the zero-degree row. Expectbulk=on the fast shapes and nobulk=on a bound edge, a filter, a wildcard, and two hops. Expect the projected number to equalcount { … } = kon that seed (2 unbound, 3 bound). Extend the report test that already checks a skipped inner tree. Add a compiler test for the projection parse, the non-nullI64andBool, and the mixed-aggregate refusal.Lance
Lance does not cause this gap, and a Lance release will not add the syntax. OmniGraph pins unmodified Lance 13.0.0 (published 2026-10-07). No open pull request in this repository upgrades Lance. Upstream
v14.0.0-beta.21(2026-10-19) notes distributed index optimization and an FTS finalize fix: https://github.com/lance-format/lance/releases/tag/v14.0.0-beta.21. The degree structure is OmniGraph’sGraphIndexCSR/CSC, built from the edge tables. Lance stores those tables. The missing piece is the GQ expression and a projecting operator.Related work
count($p) > 0is legal in a groupedreturn. It does not keep a zero-degree row, and it is not one column per edge type.needs-rfc. Optional traversals. A different cardinality.countover one hop scans the far node table #1051, open.return { count($m) }over one hop scans the far node. A faster per-group query is still N requests, and it still drops zeros.$e.@typeexpands in indexed-scan mode and is ~10× slower than its members #1057, open. A wildcard grouped by$e.@typeusesindexed_scan. The degree path does not apply to a wildcard. This projection does not depend on making that scan fast.No merged pull request and no open pull request adds a block aggregate as a projection. This issue is not a duplicate. It stays open.
Limitations
The graph is a local file store: tens of nodes, three edge types, and one hub of 3200 parallel edges. It is not the reported 0.13.0 HTTPS server, 101 node types, 552 edge types, or 47k-row table. The concurrency figures were not rerun.
explaindoes not execute, so this pass did not capture profileskippedrows. The existing report test covers that signal for filters. No code was changed.