Describe the bug
When the same graph entity is bound to multiple aliases in one result row,
sequential SET and REMOVE clauses can build a later update from stale
entity properties carried by another alias.
The query can return the result of an earlier change successfully while a
later change through another alias silently overwrites it in storage.
How are you accessing AGE (Command line, driver, etc.)?
What data setup do we need to do?
LOAD 'age';
SET search_path = ag_catalog, "$user", public;
SELECT create_graph('same_row_alias_set');
SELECT * FROM cypher('same_row_alias_set', $$
CREATE (:T {v: 1}),
(:RemoveT {drop_me: 1})
$$) AS (a agtype);
What is the necessary configuration info needed?
- No additional extension or non-default AGE configuration is required.
What is the command that caused the error?
SELECT * FROM cypher('same_row_alias_set', $$
MATCH (n:T), (m:T)
WHERE id(n) = id(m)
SET n.a = 1
SET m.b = n.a + 1
RETURN n.a, m.a, m.b
$$) AS (na agtype, ma agtype, mb agtype);
The query returns:
na | ma | mb
----+----+----
1 | | 2
Reading the stored entity afterwards:
SELECT * FROM cypher('same_row_alias_set', $$
MATCH (n:T)
RETURN n.a, n.b
$$) AS (a agtype, b agtype);
produces:
Property a, which was written by the first SET, has been lost.
The same loss occurs with constant right-hand sides:
The dependent expression is included above because the returned m.b value
of 2 shows that n.a + 1 was evaluated correctly. The lost update occurs
when the second write is built from the stale properties carried by alias
m.
REMOVE exposes the same stale-properties problem:
SELECT * FROM cypher('same_row_alias_set', $$
MATCH (n:RemoveT), (m:RemoveT)
WHERE id(n) = id(m)
SET n.changed = true
REMOVE m.drop_me
RETURN m
$$) AS (m agtype);
The returned and stored entity loses changed = true because REMOVE builds
its replacement properties from the original value carried by m.
Expected behavior
Both aliases identify the same graph entity. A later writable clause should
preserve the earlier update, and every reference to that entity in the
current result row should observe its latest state.
The query should return:
na | ma | mb
----+----+----
1 | 1 | 2
The stored entity should contain:
For the REMOVE reproduction, the stored properties should be:
Environment (please complete the following information):
- AGE master commit:
801417404978823bd8732452c3f7959017584785
- PostgreSQL: 18.4
- Access method:
psql
- OS: Linux x86-64
Additional context
Root cause
The original apply_update_list() processes each writable item using the
agtype entity value stored at that item's entity_position in the current
scanTupleSlot.
It reads original_properties directly from that alias-local entity value
and constructs altered_properties before locating the heap tuple used for
the physical update. That lookup only identifies the row to write; it does not
refresh the properties used to build the replacement entity. Its last-update
bookkeeping is also indexed by entity_position, so two different tuple
positions are treated as independent update targets even when their entity
values have the same graphid.
For the result row in the SET reproduction:
- Aliases
n and m initially contain separate agtype values representing
the same stored entity.
SET n.a = 1 reads the properties carried by n, builds the updated
entity, and stores that value in n's tuple position.
- The original
update_all_paths() updates occurrences inside paths, but
does not replace a top-level entity value stored under another alias.
- Alias
m therefore continues to carry the original properties.
SET m.b = n.a + 1 evaluates the right-hand side correctly as 2, but
apply_update_list() builds the replacement entity from m's stale
original_properties.
- The second physical update preserves
b but overwrites the earlier
property a.
The returned columns come from their individual alias slots. This explains
why the query can report the first assignment even though it has been lost
from storage.
REMOVE uses the same apply_update_list() executor path and fails for the
same reason: it removes a property from the stale alias-local property map and
writes that map back over the current entity.
Proposed fix
This issue is related to the entity-lookup performance request
issue #2484. That proposal changes how writable clauses locate
the current heap tuple by introducing CTID as a validated lookup hint, which
also changes the executor path on which this correctness fix depends.
Implementing this fix separately would require reworking the same path when
the CTID lookup is introduced. I therefore plan to address both issues in one
pull request and link that pull request to each issue.
Cleanup:
SELECT drop_graph('same_row_alias_set', true);
Describe the bug
When the same graph entity is bound to multiple aliases in one result row,
sequential
SETandREMOVEclauses can build a later update from staleentity properties carried by another alias.
The query can return the result of an earlier change successfully while a
later change through another alias silently overwrites it in storage.
How are you accessing AGE (Command line, driver, etc.)?
psqlWhat data setup do we need to do?
What is the necessary configuration info needed?
What is the command that caused the error?
The query returns:
Reading the stored entity afterwards:
produces:
Property
a, which was written by the firstSET, has been lost.The same loss occurs with constant right-hand sides:
The dependent expression is included above because the returned
m.bvalueof
2shows thatn.a + 1was evaluated correctly. The lost update occurswhen the second write is built from the stale properties carried by alias
m.REMOVEexposes the same stale-properties problem:The returned and stored entity loses
changed = truebecauseREMOVEbuildsits replacement properties from the original value carried by
m.Expected behavior
Both aliases identify the same graph entity. A later writable clause should
preserve the earlier update, and every reference to that entity in the
current result row should observe its latest state.
The query should return:
The stored entity should contain:
For the
REMOVEreproduction, the stored properties should be:Environment (please complete the following information):
801417404978823bd8732452c3f7959017584785psqlAdditional context
Root cause
The original
apply_update_list()processes each writable item using theagtype entity value stored at that item's
entity_positionin the currentscanTupleSlot.It reads
original_propertiesdirectly from that alias-local entity valueand constructs
altered_propertiesbefore locating the heap tuple used forthe physical update. That lookup only identifies the row to write; it does not
refresh the properties used to build the replacement entity. Its last-update
bookkeeping is also indexed by
entity_position, so two different tuplepositions are treated as independent update targets even when their entity
values have the same graphid.
For the result row in the
SETreproduction:nandminitially contain separate agtype values representingthe same stored entity.
SET n.a = 1reads the properties carried byn, builds the updatedentity, and stores that value in
n's tuple position.update_all_paths()updates occurrences inside paths, butdoes not replace a top-level entity value stored under another alias.
mtherefore continues to carry the original properties.SET m.b = n.a + 1evaluates the right-hand side correctly as2, butapply_update_list()builds the replacement entity fromm's staleoriginal_properties.bbut overwrites the earlierproperty
a.The returned columns come from their individual alias slots. This explains
why the query can report the first assignment even though it has been lost
from storage.
REMOVEuses the sameapply_update_list()executor path and fails for thesame reason: it removes a property from the stale alias-local property map and
writes that map back over the current entity.
Proposed fix
This issue is related to the entity-lookup performance request
issue #2484. That proposal changes how writable clauses locate
the current heap tuple by introducing CTID as a validated lookup hint, which
also changes the executor path on which this correctness fix depends.
Implementing this fix separately would require reworking the same path when
the CTID lookup is introduced. I therefore plan to address both issues in one
pull request and link that pull request to each issue.
Cleanup: