Skip to content

Duplicate graph resolution on every traversal call (~21% of the per-call fixed cost) #18

Description

@ysys143

Summary

ensure_current_graph() (graph/src/sql_facade/runtime.rs:573) resolves the selected graph, and then calls current_catalog_state()read_catalog(), which resolves the same graph again through selected_or_default_graph_id_via_definer(). Every traversal call pays for two definer SPI lookups of the same catalog row.

Removing the duplicate cuts the per-call fixed cost by ~21%.

Environment

  • ghcr.io/evokoa/pggraph:1.0.0, PostgreSQL 17.10, linux/arm64
  • Graph: 1,308,553 nodes / 3,599,812 edges (4 registered tables, 3 edge types)
  • 4 vCPU / 6 GB VM, single connection

Measurement

Same pinned seed set for both builds, warmup applied, median of 40 runs. graph.status() is a negative control on a path the change does not touch.

metric 1.0.0 duplicate removed delta
minimal-work expand() (fixed cost) 5.179 ms 4.070 ms -21.4%
expand() 1 hop, LIMIT 50 6.148 ms 4.923 ms -19.9%
expand() 1 hop reverse, LIMIT 50 4.980 ms 4.217 ms -15.3%
expand() 2 hop, LIMIT 50 7.242 ms 7.033 ms -2.9%
graph.status() (negative control) 3.495 ms 3.683 ms +5.4%

Row counts were identical in every case (160 / 141 / 1,183). The negative control puts run-to-run noise at ±5%, so the fixed-cost reduction is above noise. The 2-hop case is dominated by traversal rather than call setup, which is why its share is small.

Context

While benchmarking traversal on this graph I found a per-call floor of roughly 3.5 ms that does not amortize:

condition result
graph size 6.5K → 1.3M nodes 3.56 ms → 3.46 ms (unchanged)
sync_mode trigger → manual no material change
three calls inside one transaction 4.89 / 5.33 / 4.57 ms (no amortization)
graph.status() alone (no traversal, one row) ~3.5 ms

Tracing it, ensure_current_graph() issues roughly ten SPI queries per call across graph._graphs + pg_roles, pg_trigger, the three _registered_* tables, and the sync log. A single one of those catalog queries measures ~0.38 ms warm, which accounts for the floor.

I understand most of these are deliberate — schema-drift, pending-sync and ACL state are verified on every call by design. This report is limited to the one lookup that looks unambiguously redundant, since the graph id is already in hand at that point.

Proposed change

Add current_catalog_state_for_graph(graph_id) that calls read_catalog_for_graph() directly, and use it from ensure_current_graph(). read_catalog() already delegates to read_catalog_for_graph() once it has resolved the id, so the executed catalog reads are the same minus the duplicate resolution.

PR: (link)

Schema-drift behavior remains covered by the existing pg_tests catalog_drift_requires_rebuild and schema_drift_detects_live_ddl_changes.

Question

Is per-call catalog verification a hard requirement for correctness, or would backend-local caching of the remaining lookups — invalidated through PostgreSQL relcache/syscache invalidation callbacks on the graph._* catalog tables — be an acceptable direction? That would address the rest of the floor, but it changes when drift is observed, so I did not attempt it without maintainer input.

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