Skip to content

Same-row alias updates can overwrite earlier changes to the same entity #2485

Description

@crdv7

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.)?

  • psql

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:

 a | b
---+---
   | 2

Property a, which was written by the first SET, has been lost.

The same loss occurs with constant right-hand sides:

SET n.a = 1
SET m.b = 2

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:

 a | b
---+---
 1 | 2

For the REMOVE reproduction, the stored properties should be:

{"changed": true}

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:

  1. Aliases n and m initially contain separate agtype values representing
    the same stored entity.
  2. SET n.a = 1 reads the properties carried by n, builds the updated
    entity, and stores that value in n's tuple position.
  3. The original update_all_paths() updates occurrences inside paths, but
    does not replace a top-level entity value stored under another alias.
  4. Alias m therefore continues to carry the original properties.
  5. 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.
  6. 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);

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions