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.
Summary
ensure_current_graph()(graph/src/sql_facade/runtime.rs:573) resolves the selected graph, and then callscurrent_catalog_state()→read_catalog(), which resolves the same graph again throughselected_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/arm64Measurement
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.expand()(fixed cost)expand()1 hop,LIMIT 50expand()1 hop reverse,LIMIT 50expand()2 hop,LIMIT 50graph.status()(negative control)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:
sync_modetrigger → manualgraph.status()alone (no traversal, one row)Tracing it,
ensure_current_graph()issues roughly ten SPI queries per call acrossgraph._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 callsread_catalog_for_graph()directly, and use it fromensure_current_graph().read_catalog()already delegates toread_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_testscatalog_drift_requires_rebuildandschema_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.