Skip to content

Research: should GFQL chains be immutable? (execution re-validates a mutation the product never performs) #2091

Description

@lmeyerov

Research question, not a proposal. Opened to capture an audit finding and decide whether it is worth pursuing; there is no recommended action yet.

The question

GFQL query chains are frozen in practice but explicitly mutable by contract. Every execution re-validates the ops to defend a mutation the product never performs. Is that contract worth its per-query cost, and would enforcing immutability be better?

What the audit found

ASTObject / ASTNode / ASTEdge / Chain are plain classes — not frozen dataclasses, no __slots__, no __setattr__ guard — so anything about them is writable after construction.

Nothing in the product mutates them. Searching graphistry/ outside tests for assignments to op attributes, to chain[i].<attr>, or to Chain.chain after construction returns nothing.

But mutation is a tested, intentional contract. graphistry/tests/compute/test_chain_validation_execution.py::test_execution_revalidates_mutated_ast mutates edge.hops between runs and requires every execution to raise E103, parametrized across:

  • both entry points (chain, gfql)
  • wrapped and unwrapped (Chain(ops) and a raw list)
  • all engines

So this is not defensive coding that crept in. Someone decided a mutated AST must be caught at execution time, every time, however the caller holds it.

What it costs

Measured on LDBC SNB SF0.1, polars, in-process with an A/A control:

ms/query
AST validation before #2088 (seed-lookup) 0.075
after #2088, which removed the redundant second pass on the gfql path 0.038
remaining, i.e. what enforcing immutability could additionally remove ~0.038

#2088 removed one of two passes and only where gfql built the chain in the same call — precisely because the contract above forbids touching the case where a caller supplies their own container. Enforcing immutability would let the remaining pass go for every caller.

The trade

For: validated becomes an invariant rather than a hope; the skip logic and its marker disappear; ~0.038 ms/query back on every query, every engine; a whole class of "did this change under us" reasoning goes away.

Against: it is a breaking API change. Any user code that builds a chain and then adjusts it would start failing, and the behaviour is currently tested, so it is fair to assume someone relies on it. It would need a deprecation path, and the payoff is small in absolute terms.

What would settle it

  1. Is mutating a built chain a use case anyone actually has, or an artifact of the objects being plain classes?
  2. If it is a use case, is replace-style copy-on-write an acceptable migration?
  3. Is ~0.038 ms/query worth a deprecation cycle, or is this only worth doing if immutability is wanted for its own sake (thread-safety, caching by identity, structural sharing)?

Filed from a performance audit; not blocking any open PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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