Skip to content

GFQL: two-hop count returns a STALE answer after in-place frame mutation (degree-count memo setattrs onto the caller's Plottable, keyed by id() — the BLOCKER-1 pattern the same file forbids) #1825

Description

@lmeyerov

Summary

_two_hop_cached_equal_domain_degree_counts caches degree frames by setattr-ing onto the caller's Plottable, keyed by id(nodes_obj)/id(edges_obj). After an in-place mutation of the caller's node/edge frame, the next gfql() on the same Plottable returns the stale answer, silently.

This is the exact failure the same file warns against, five times, forty lines above (gfql_fast_paths.py:206-208, and at :245, :282, :319, :373):

"Per-execution cache only (threaded via cache_store); NEVER setattr onto the caller's Plottable -- that leaked results across gfql() calls keyed by id(), returning stale answers after an in-place edge/node mutation (BLOCKER 1)."

_two_hop_cached_equal_domain_degree_counts (gfql_fast_paths.py:490) does precisely that.

Reproduction — verified on origin/master 1537e4676

dgx-spark, graphistry/test-rapids-official:26.02-gfql-polars, --gpus all, master tree mounted read-only. Query is the canonical two-hop count (equal node domains, equal edge match):

Q = ("MATCH (a {node_type:'Person'})-[{rel:'FOLLOWS'}]->(b {node_type:'Person'})"
     "-[{rel:'FOLLOWS'}]->(d {node_type:'Person'}) RETURN count(*) AS numPaths")

nodes = pd.DataFrame({"id":[0,1,2,3], "node_type":["Person"]*4})
edges = pd.DataFrame({"src":[0,1,1], "dst":[1,2,3], "rel":["FOLLOWS"]*3})
g = graphistry.nodes(nodes,"id").edges(edges,"src","dst")

g.gfql(Q)                      # -> 2   (correct)
edges.loc[2,"rel"] = "BLOCKS"  # in-place: edge 1->3 is no longer FOLLOWS
g.gfql(Q)                      # -> 2   *** WRONG, expected 1 ***

graphistry.nodes(nodes,"id").edges(edges,"src","dst").gfql(Q)   # -> 1  (ground truth)

Engagement is asserted, not assumed — the memo helper was instrumented, and the run records memo_called=3, memo_HIT=1 (a genuine cache hit, not a coincidental value match):

engine memo called / HIT before after in-place mutation fresh Plottable, same frames
polars 3 / 1 2 2 1
pandas 3 / 1 2 2 1
polars-gpu 3 / 1 2 2 1

All engines, not just polars — there are two call sites in _execute_two_hop_count_fast_path, one in the POLARS_ENGINES branch (:1910) and one in the else arm (:1960).

Why id() keying cannot work here

id() is stable across in-place mutation (the object is the same; only its contents changed) and is reused after garbage collection. So the key is simultaneously too coarse (misses mutation) and unsound (a new frame can inherit a dead frame's id and collide). The sibling caches avoid both by threading a per-execution cache_store instead of attaching to the caller.

Fix

Bring it to BLOCKER-1 parity: make it a per-execution cache threaded through cache_store rather than setattr onto base_graph.

Disclosure — this fix has a benchmark cost, and that should be stated up front

The cross-call memo is what makes the graph-benchmark q8 cell fast. Measured on dgx (RAPIDS 26.02, --gpus all, perf lock, RUNS=31, position-balanced, values identical across arms):

scale q8 with warm memo q8 with memo disabled
20k 2.11 / 2.32 ms 11.98 ms
100k 5.31 / 5.77 ms 53.84 ms

And the memo only pays off when a Plottable is reused across calls, which the benchmark harness does but ordinary one-shot use does not:

q8 reused Plottable q8 fresh Plottable Kuzu, same session
20k 2.31 – 2.74 ms 14.23 – 21.54 ms 2.79 – 4.22 ms
100k 5.60 – 5.87 ms 52.79 – 53.59 ms 9.37 – 17.72 ms

So q8's published win is 6–9× better in the harness than the same query issued one-shot, and one-shot it is a 4–6× loss rather than a win. (q9 is mode-insensitive by contrast — 39.7 vs 39.0 ms at 100k — so that cell transfers.)

Fixing this correctness bug will cost q8 its board cell. That is the correct trade: a cached wrong answer is not a performance feature. Filing it explicitly so the number moves for a stated reason rather than quietly.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions