Optimize writable entity lookup and preserve same-row alias updates - #2486
Open
crdv7 wants to merge 1 commit into
Open
Optimize writable entity lookup and preserve same-row alias updates#2486crdv7 wants to merge 1 commit into
crdv7 wants to merge 1 commit into
Conversation
Propagate hidden CTID hints through eligible MATCH and WITH clauses to avoid repeated graphid lookups during SET, REMOVE, and DELETE. Treat CTID as a validated physical hint. Fall back to a usable non-partial B-tree index whose first key is the entity id, and use a sequential scan only when no suitable index exists. Reuse lookup slots and index relations for the lifetime of SET, DELETE, and MERGE statements. Fix a pre-existing issue where sequential SET or REMOVE clauses through different aliases of the same entity could build an update from stale properties and overwrite an earlier change. Refresh the base properties from the current heap tuple when an alias carries a missing or stale CTID, and synchronize top-level aliases and paths with the same graphid. Avoid propagating hidden CTID entries through aggregation, DISTINCT, computed expressions, or internal parser variables, where they could change query semantics or become associated with the wrong entity. Add regression coverage for stale CTID fallback, same-entity vertex and edge aliases, dependent right-hand-side expressions, REMOVE, DELETE, WITH aliases, aggregation, DISTINCT, and user-created edge id indexes. Closes apache#2484 Closes apache#2485
crdv7
force-pushed
the
optimize-writable-entity-lookup
branch
from
July 31, 2026 07:46
2634589 to
1d1e8fe
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This change avoids redundant graphid lookups when
SET,REMOVE, orDELETEwrites an entity already read byMATCH.For writable statements, AGE now carries the matched tuple's
ctidas ahidden internal lookup hint. The executor validates the tuple's snapshot
visibility and graphid before using it, and falls back to the existing
graphid lookup when the hint is missing or stale. Graphid remains the logical
entity identity.
The same executor path also contained a correctness issue: when two aliases
in one result row represented the same entity, a later
SETorREMOVEcould rebuild the entity from stale alias-local properties and overwrite an
earlier change. This change refreshes the current properties and keeps
same-graphid aliases and paths synchronized.
The resulting lookup order is:
No Cypher syntax or user-visible result columns are added.
Implementation
Hidden CTID propagation
MATCH, but only when the statement containsSET,REMOVE, orDELETE.WITH nandWITH n AS aliasprojections.DISTINCT, computed expressions, andparser-generated variables, where a hidden value could change row-shaping
semantics or become associated with the wrong entity.
it in ExtensibleNode copy/out/read support.
PostgreSQL's native
tidis packed into a scalar agtype value for transportthrough AGE target lists:
Read-only
MATCH/RETURNstatements do not receive hidden target entries.Shared entity lookup
find_entity_tuple()centralizes tuple lookup for the writable executors.the expected graphid.
entity
id.idindex exists.This remains correct if a user drops a default vertex
idindex. It alsoallows a user-created edge
idindex to serve as the fallback. The defaultedge
start_idandend_idindexes cannot resolve an edge by graphid.A statement-local cache retains the tuple fetch slot, opened
idindexrelation, and negative index-search result.
SETandDELETEown that cachefor their custom-scan lifetime.
CTID is always a hint rather than identity. A previous writable clause may
move the tuple and leave another alias with a stale CTID; the validation and
graphid fallback prevent an update or delete from targeting the wrong row.
After an update, the new CTID is propagated for later writes in the same
result row.
Same-row alias consistency
The original
apply_update_list()built replacement properties from theagtype value stored at each update item's tuple position before locating the
current heap tuple. Different aliases were treated as independent positions
even when they contained the same graphid, and the existing synchronization
pass updated entities inside paths but not top-level aliases.
For example:
The query could return
1, NULL, 2while the stored entity containeda = NULL, b = 2.The updated executor locates the current heap tuple before building the first
update for an entity position, refreshes stale base properties from that
tuple, synchronizes top-level aliases and paths with the same graphid, and
reuses the tuple for the physical update.
REMOVEuses the same path andreceives the same consistency fix.
DELETEdoes not reconstruct properties, but it uses the validated lookuphelper so a stale alias CTID after an earlier
SETcannot cause the wrongtuple to be deleted.
The correctness fix and the lookup optimization are included together
because they share the same
apply_update_list()control flow: the executormust locate the current tuple before rebuilding properties, synchronize
same-entity references, and then reuse that tuple for the physical update.
Splitting them would require implementing and immediately replacing an
intermediate lookup path. If a correctness-only backport is requested, it
can be adapted to use the existing graphid fallback first.
MERGE fallback cache
MERGEuses a separate lateral/eager custom-scan transform that does notcurrently carry a reliable entity-to-CTID column position into its SET
metadata, so it does not receive a hidden CTID fast path in this change. When
a plan contains
ON MATCH SETorON CREATE SET, it creates the sharedgraphid fallback cache for the statement lifetime and releases it when the
custom scan ends.
Suggested review order
src/backend/executor/cypher_utils.c: shared CTID/index/SeqScan lookup.src/backend/parser/cypher_clause.c: hidden CTID generation andWITHpropagation.
src/backend/executor/cypher_set.c: current-property refresh andalias/path synchronization.
src/backend/executor/cypher_delete.candsrc/backend/executor/cypher_merge.c: lookup/cache integration.Performance
Environment and method
801417404978823bd8732452c3f79590175847851d1e8fe4a289eae83b7873a0bc534fa2bccd2cad--disable-debug --disable-cassertandCFLAGS=-O2 -gshared_buffers=1GB,fsync=off,synchronous_commit=off,full_page_writes=off,autovacuum=off,jit=offEach host ran the same complete benchmark matrix. PostgreSQL and its child
backends were pinned to vCPU 2 to avoid migration between virtual CPUs.
Only
age.sochanged between variants. Data reset,VACUUM ANALYZE, serverrestart, and shared-library replacement were outside the timed interval.
The read-only control executes 20 prepared queries in one backend and reports
the per-query time, avoiding client startup and cold-backend costs from
dominating this short control. Controls use 15 measured AB/BA-interleaved
runs; writable and edge workloads use five. Both variants receive an
unreported warm-up. Negative change means the patch is faster.
MATCH/countCREATEcontrolSETREMOVEDELETEDELETEMERGE ON MATCH SETSET, no edgeidindexSET, no edgeidindexSET, no edgeidindexDISTINCTedgeSET, edgeidindexThe controls remain within approximately 1% on both hosts. In particular,
read-only statements do not receive hidden CTID target entries and show no
material regression; their elapsed differences are smaller than the measured
run-to-run variation. CV is at most 1.63% for the 100,000-vertex
DELETEresults and 3.48% for the 100,000-edge no-index results.
DELETE, which isolates entity lookup most clearly, improves by 16–18% onboth CPUs.
REMOVEimproves by 3–5%.SETspends most of its time rebuildingproperties and updating PostgreSQL tuples, so its statement-level result is
small and hardware-dependent.
MERGE, which uses only the fallback cache inthis change, remains neutral.
The edge-label case shows the largest and most consistent benefit. Without a
user-created
idindex, the baseline performs a sequential graphid lookupfor every edge. Increasing the label from 10,000 to 100,000 edges increases
baseline time by roughly two orders of magnitude, while the patch reduces
100,000-edge elapsed time by 86–87% on both CPUs.
Benchmark reproduction
A self-contained reviewer script builds the baseline and patched commits,
creates an isolated PostgreSQL 18 cluster, runs the workloads in AB/BA order,
and writes raw and summarized CSV files. It neither installs AGE into the
PostgreSQL prefix nor modifies an existing cluster.
repro_benchmark.sh
From an AGE checkout on the pull request branch:
This command reproduces the timing matrix above, including 15 control runs,
five writable/edge runs, and the 500,000-vertex
DELETEcase. Because theno-index 100,000-edge baseline is intentionally slow, reviewers can first
validate their environment with:
SERVER_CPU=2 READ_ONLY_BATCH_COUNT=20 ./repro_pr_benchmark.sh \ --age-repo "$PWD" \ --pg-config /path/to/postgresql-18/bin/pg_config \ --quickThe main output is
all-summary.csv; per-group raw CSV, build logs,PostgreSQL logs, and an environment manifest are retained alongside it.
Choose a
SERVER_CPUavailable on the test host.Standalone edge-label benchmark
The following self-contained script creates an edge label with only the
default
start_idandend_idindexes, loads the requested number of edges,prints the actual indexes, runs
EXPLAIN ANALYZE, and verifies the updatedrow count:
repro_edge_set_no_id_index.sql
CPU profile
The 2,000,000-vertex
DELETEworkload was sampled at 199 Hz withcycles:uand DWARF call graphs:In the baseline, stacks below
process_delete_list()containingindex_getnext_slotaccount for 16.56% of sampled cycles:That stack disappears from the patched profile. Its replacement accounts for
3.09% of patched sampled cycles:
Baseline flame graph:
Patched flame graph:
Five-run
perf statmedians for the 500,000-vertexDELETEworkload:The elapsed-time improvement is accompanied by comparable reductions in
instructions and branches, supporting a CPU-work reduction rather than an
I/O-only effect.
Tests
Built and tested against PostgreSQL 18.4:
cypher_set,cypher_remove,cypher_delete,cypher_with,cypher_vle, andcypher_merge;security/RLS suite passed withAddressSanitizer and
MEMORY_CONTEXT_CHECKING;43/43.
New regression coverage includes:
SET;SETandREMOVE;WITHreferences and renames;DISTINCT, and computed-expression propagation boundaries;WITH *andRETURN *without leaking hidden CTID columns;REMOVE,DELETE, andDETACH DELETE;idB-tree index;idindex;Related issues
Closes #2484
Closes #2485