Skip to content

refactor(cypher): port the cypher module to Zig - #109

Merged
ata-sesli merged 3 commits into
mainfrom
refactor/cypher-zig
Nov 3, 2026
Merged

ata-sesli merged 3 commits into
mainfrom
refactor/cypher-zig

Conversation

@ata-sesli

Copy link
Copy Markdown
Member

Summary

This moves the Cypher query engine (src/cypher/) from C to Zig, following the same approach as the earlier git, simhash, watcher and discover ports: cypher.h stays the interface, including every AST, token and result struct layout, and the Zig files export the same cbm_* functions. No caller changes. After this PR, src/cypher/ has no .c files.

Queries should return exactly what they returned before: the same rows, columns, warnings and error text. The third commit fixes one existing bug in the engine, a data race on edge property values, described below.

Commits

Each commit builds and passes the test suite on its own.

  1. refactor(cypher): port the lexer and parser to Zig. Moves the lexer, the recursive-descent parser and the AST free functions to src/cypher/parser.zig. The exports are cbm_lex, cbm_lex_free, cbm_parse, cbm_parse_free, cbm_query_free and cbm_cypher_parse. The AST is still made of the C structs from cypher.h and allocated with libc, because C callers and tests read and free it. cypher.c keeps the executor for this commit, with small static copies of the three helpers it shares with the parser.

  2. refactor(cypher): port the executor to Zig. Moves the rest of cypher.c to src/cypher/cypher.zig and deletes the C file. This covers:

    • pattern scan and expansion, including variable-length traversal;
    • WHERE evaluation, including regular expressions through the existing cbm_regcomp/cbm_regexec wrappers;
    • WITH and RETURN projection, aggregation, ORDER BY / SKIP / LIMIT, DISTINCT, UNION and UNWIND;
    • the cross join, the scope and variable-capacity checks, the result ceiling and the 30-second deadline.

    The exports are cbm_cypher_execute, cbm_cypher_result_free, cbm_cypher_classify_plan, cbm_cypher_cross_join_alloc and the two deadline test hooks.

  3. fix(cypher): keep the edge property buffers per thread. See "Bug fix" below.

Build wiring:

  • build.zig and Makefile.cbm drop cypher.c and list the Zig files in CBM_ZIG_SRCS.
  • src/cbm_zig.zig runs checkExports on both modules, so a signature that drifts from cypher.h fails the build.
  • src/cbm_zig_c.h adds the headers the engine needs (cypher/cypher.h, foundation/compat_regex.h, foundation/limits.h).

How output is kept identical

  • Number formatting (%g, %.10g, %lld) still goes through libc snprintf. Zig's formatter has no %g and prints floats differently.
  • Number parsing still uses strtol/strtoll/strtod/atoi, which accept input that Zig's parsers treat differently (leading whitespace, partial numbers, inf).
  • Integer narrowing that C does with a cast becomes @truncate, and arithmetic that C lets wrap uses wrapping operators. Since build: link test runners against a ReleaseSafe Zig library #108 the test runners link a ReleaseSafe Zig library, so a missed case would panic in the tests instead of silently differing.
  • Fixed buffers keep their sizes, so long values are cut at the same points.
  • Thread-local state (the deadline, the traversal flags, the node property buffers) stays thread-local.

Bug fix: edge property values could leak between concurrent queries

When a query reads an edge property such as r.url_path, the engine first copies the value into one of eight rotating scratch buffers and then copies it into the result row. The node property buffers were thread-local, but the edge property buffers and their index were a plain process-wide static in the C code. Two queries running on different threads could therefore write into the same buffer, and a row could end up with the other query's value.

The second commit ports this unchanged, so it stays a true port. The third commit makes the edge buffers thread-local, like the node buffers.

The new test cypher_edge_prop_concurrent_queries_isolated runs the same edge-property query 3,000 times on each of four threads, each against its own store with a different url_path. Before the fix, 796 of the 768,000 values read came from another thread. After the fix, none did. The test adds about 6 seconds to the cypher suite.

Verification

All runs were on macOS arm64.

  • Full test suite with CBM_TEST_TIERS=all: 6,447 passed on main (665f1d5) and after each of the first two commits. After the third commit, 6,448 passed, which is the same tests plus the new one.

  • Focused suites after the fix: cypher 221, security 34, zova_plugin 8 and zova 39 passed. These are the suites that call the query engine.

  • Old vs new query output. I built main and this branch as separate CLI binaries and indexed this repository's source tree once for each, each binary with its own cache. Then I ran 352 queries through cli query_graph on both binaries, one process at a time. The queries were:

    • 277 Cypher strings extracted from the C tests (test_cypher.c, test_security.c and the cypher repro tests);
    • 75 hand-written queries covering each clause and function in cypher.h and each unsupported keyword.

    349 responses were byte-identical, apart from elapsed_ms, and none differed. The other 3 queries match every Module against every Function with nothing linking them, so they produce very large results. They passed the 2 GB memory cap I set for the comparison on both binaries, so their output was not compared.

  • Timing. These are 12 heavy queries on the same index, run 5 times each with old and new alternating. The table shows median wall time per CLI process.

    Query main ms branch ms ratio
    MATCH (n:Project) RETURN n.name (process start-up baseline) 114 114 1.00
    MATCH (a)-[:CALLS*1..4]->(b) RETURN count(b) 27,943 28,779 1.03
    MATCH (a:Function)-[:CALLS*]->(b:Function) RETURN count(DISTINCT b.name) 32,957 32,886 1.00
    file-level aggregation over Function 527 511 0.97
    top callers by count with ORDER BY 2,504 2,472 0.99
    MATCH (n) … ORDER BY … LIMIT 5000 790 777 0.98
    MATCH (a:File), (b:Folder) RETURN count(*) 33 33 1.00
    OPTIONAL MATCH + WITH filter 2,668 2,675 1.00
    edge counts by type(r) 6,165 6,128 0.99
    WITH + collect 584 561 0.96
    RETURN DISTINCT with ORDER BY 799 784 0.98
    two-hop CALLS count 7,102 7,065 0.99

    In one of the five pairs of CALLS*1..4 runs, the two outputs differed. A separate run of each binary gave identical output: the same count and the same "traversal budget was exhausted" warning. That query runs for 26–29 seconds against the engine's 30-second wall-clock deadline, so the likely cause is that one run hit the deadline. I did not confirm this, because the outputs of those runs were not saved.

  • Cross-compile of libcbm_zig.a succeeded for x86_64 and aarch64 Linux (gnu), x86_64 Windows (gnu) and aarch64 macOS. All 12 cypher exports were present in each archive.

  • Header drift check: I temporarily changed the first parameter of cbm_cypher_cross_join_alloc in cypher.h to long. The build failed with cbm_cypher_cross_join_alloc: parameter 0 is c_int in Zig but c_long in its C header. The change was then reverted.

  • Lint: clang-format-diff (clang-format 22.1.8) is clean on the changed lines of tests/test_cypher.c. zig fmt --check, git diff --check and tests/test_github_workflows.sh all pass.

Limits

  • Windows was only cross-compiled, not run. PR CI runs Linux amd64 only.
  • Stale comments: comments in tests/repro/repro_issue627.c and tests/repro/repro_new_cypher_limit_zero.c still cite cypher.c line numbers. They are left as they are, as in the discover port.
  • The output comparison used this repository's index only. A second, larger index was prepared but not run, to keep the comparison short.

Move the Cypher lexer, recursive-descent parser and AST free functions
from cypher.c to src/cypher/parser.zig. cypher.h stays the ABI: the
exports (cbm_lex, cbm_lex_free, cbm_parse, cbm_parse_free,
cbm_query_free, cbm_cypher_parse) keep their signatures, the AST is
built from the translated C structs with libc allocation, and every
error string is unchanged.

cypher.c keeps the executor for now, with small static copies of
heap_strdup, cyp_ci_eq and is_scalar_value_func, which the executor
also uses.

Signed-off-by: ata-sesli <atasesli@protonmail.com>
Move the rest of cypher.c to src/cypher/cypher.zig and delete the C
file: pattern scan and expansion, WHERE evaluation, WITH and RETURN
projection, aggregation, ORDER BY/SKIP/LIMIT, DISTINCT, UNION, the cross
join, the scope and variable-capacity checks, the deadline and the
result builder. The exports (cbm_cypher_execute, cbm_cypher_result_free,
cbm_cypher_classify_plan, cbm_cypher_cross_join_alloc and the two
deadline test hooks) keep their cypher.h signatures and are checked
against the header at compile time.

Behaviour is kept byte for byte: %g/%lld formatting and number parsing
still go through libc, fixed buffers keep their sizes, error and
warning text is unchanged, and C narrowing becomes @truncate so the
ReleaseSafe test library does not panic where C wrapped.

parser.zig exports the shared helpers (CStr, ix, the allocation
wrappers) to the executor. src/cypher/ no longer has any C source.

Signed-off-by: ata-sesli <atasesli@protonmail.com>
edgeProp extracts each edge property into a ring of eight scratch
buffers before the value is copied into a row. The ring and its index
were process-wide (a plain static in the C engine, ported unchanged),
so queries executing on different threads wrote into each other's
slots and a row could pick up another query's value. The node property
ring was already thread-local; the edge ring now is too.

The new test runs the same edge-property query on four threads, each
against its own store with a distinct url_path. Before the fix it saw
796 foreign values out of 768,000; after it, none.

Signed-off-by: ata-sesli <atasesli@protonmail.com>
@ata-sesli
ata-sesli merged commit b8b46b8 into main Nov 3, 2026
10 checks 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