Skip to content

Writes are silently discarded when a later clause reads a label whose table is empty (DML CustomScan never executed) #2494

Description

@gregfelice

Describe the bug

In a multi-part query, all writes can be silently discarded — not merely invisible to later clauses, but never executed at all. The statement succeeds, returns a plausible result, and leaves the database unchanged.

AGE's DML executes inside a CustomScan buried in the plan tree rather than as a top-level ModifyTable. The planner is therefore free to place that subtree on either side of a join, and several executor nodes are entitled to skip an input entirely when the other input turns out to be empty. When the skipped input is the one containing the Cypher Create node, the writes never happen.

The empty input is typically the scan of the very label the query is writing to — which is empty precisely because the node that would populate it is the side being skipped. Chicken-and-egg, and completely silent.

Split out of #2491, which hits this as well as the separate visibility defect tracked in #2493. This issue is only the discarded-writes half.

How are you accessing AGE (Command line, driver, etc.)?

Command line, psql.

What data setup do we need to do?

None beyond an empty graph.

LOAD 'age';
SET search_path = ag_catalog, public;
SELECT create_graph('g');

What is the necessary configuration info needed?

None. Default configuration, no extra extensions.

What is the command that caused the error?

SELECT * FROM cypher('g', $cypher$
  UNWIND [1,2] AS i CREATE (:N)-[:R {id:i}]->(:N)
  WITH count(*) AS ig
  MATCH ()-[r:R]->() RETURN count(r)
$cypher$) AS (v agtype);
 v
---
 0
(1 row)

No error is raised. Afterwards the graph is empty — zero nodes and zero edges:

SELECT * FROM cypher('g', $cypher$ MATCH (n) RETURN count(n) $cypher$) AS (c agtype);
 c
---
 0

EXPLAIN (ANALYZE) shows the writes were never executed:

 Aggregate (actual rows=1.00 loops=1)
   ->  Merge Join (actual rows=0.00 loops=1)
         ->  Merge Append (actual rows=0.00 loops=1)
               ->  Index Only Scan using _ag_label_vertex_pkey ... (actual rows=0.00 loops=1)
               ->  Index Only Scan using "N_pkey" on "N" ...      (actual rows=0.00 loops=1)
         ->  Materialize (never executed)
               ->  Nested Loop (never executed)
                     ->  Index Scan using "R_end_id_idx" on "R" r (never executed)
                     ->  ...
                           ->  Custom Scan (Cypher Create) (never executed)

The merge join exhausts its left input, concludes the join can produce nothing, and never pulls the right input — which is where the CREATE lives.

It is not specific to merge joins. The query as filed in #2491 reaches the same outcome through a hash join, where an empty build side lets PostgreSQL skip reading the outer relation entirely:

Hash Join (actual rows=0.00 loops=1)
  ->  Hash Join (never executed)
        ...
        ->  Custom Scan (Cypher Create) (never executed)   <- the nodes
              ...
              ->  Custom Scan (Cypher Create) (never executed)   <- the edges
  ->  Hash (actual rows=0.00 loops=1)
        ->  Seq Scan on "N" _age_default_alias_6 (actual rows=0.00 loops=1)

Two different join strategies, same failure — so this is not one unlucky plan shape.

What decides whether it fires

Plan shape, not input-row count. Measured on master @ 8014174:

Variant Writes persisted?
Empty graph; later MATCH scans the label just written; 2 input rows ❌ discarded
Same, 1 input row (CREATE (:N)-[:R {id:1}]->(:N)) ❌ discarded
Same, non-aggregating WITH i AS keep instead of WITH count(*) ❌ discarded
Same query, but the graph already contains one :R edge ✅ persisted
#2491 as filed (three stages, fresh graph) ❌ discarded
Later MATCH scans a different empty label than the one written ✅ persisted
#2490 as filed (single CREATE, later MATCH with WHERE r.id = item.id) ✅ persisted

The failing cases share one property: the scan of the label being written back has no visible rows at execution time. Pre-existing rows in that table are enough to make the same query behave.

Expected behavior

A write clause must execute for every input row it receives, regardless of the join strategy chosen for clauses that read afterwards. Query results may be debatable under AGE's current visibility rules (see #2493), but a CREATE that the user asked for must never be silently skipped.

Environment (please complete the following information):

  • Version: master @ 8014174 (1.8.0)
  • PostgreSQL: 18.4 (Debian), built from source
  • OS: Debian, Linux 6.12

Also reproduces on 1.7.0 — the original report in #2491 was against the 1.7.0 Docker image.

Additional context

Severity is worth noting: #2493 produces wrong answers, which is bad but visible. This one silently drops committed-looking work with no error, so an application has no way to detect that its writes did not happen short of reading back.

Fixing it likely means preventing the planner from placing the DML subtree where the executor may skip it, rather than patching any individual join node — the two reproductions above already show two independent paths to the same outcome.

Related: #2491 (original report, also covers the visibility defect), #2493 (visibility defect), #2490.

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