Repeating a destructive multi-row AGE query returns extra rows even without rollback
- Dominant language
- C
- Stars
- 4.8k
- Forks
- 523
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 9
Description
## Environment
- Apache AGE: `1.8.0`
- PostgreSQL: `18.6`
- Docker image: `apache/age:release_PG18_1.8.0`
-
## Reproduction
Run the setup as one statement. Run the target statement once, let it commit,
then run the same target statement again as a second autocommit statement. Do
not use `ROLLBACK`.
### 1. Setup
```sql
LOAD 'age';
SET search_path = ag_catalog, public;
SELECT ag_catalog.create_graph('age_issue21_repeated_write');
SELECT *
FROM ag_catalog.cypher('age_issue21_repeated_write', $$
CREATE (n0 {id: 0, k2: 0, k4: 'x', k3: false, k10: ['x']})
CREATE (n1 {id: 1, k2: 1, k4: 'x', k3: false, k10: ['x']})
CREATE (n2 {id: 2, k2: 2, k4: 'x', k3: false, k10: ['x']})
CREATE (n3 {id: 3, k2: 3, k4: 'x', k3: false, k10: ['x']})
CREATE (n4 {id: 4, k2: 4, k4: 'x', k3: false, k10: ['x']})
CREATE (n5 {id: 5, k2: 5, k4: 'x', k3: false, k10: ['x']})
CREATE (n6 {id: 6, k2: 6, k4: 'x', k3: false, k10: ['x']})
CREATE (n7 {id: 7, k2: 7, k4: 'x', k3: false, k10: ['x']})
CREATE (n8 {id: 8, k2: 8, k4: 'x', k3: false, k10: ['x']})
CREATE (n9 {id: 9, k2: 9, k4: 'x', k3: false, k10: ['x']})
CREATE (n10 {id: 10, k2: 10, k4: 'x', k3: false, k10: ['x']})
CREATE (n11 {id: 11, k2: 11, k4: 'x', k3: false, k10: ['x']})
CREATE (n12 {id: 12, k2: 12, k4: 'x', k3: false, k10: ['x']})
CREATE (n13 {id: 13, k2: 13, k4: 'x', k3: false, k10: ['x']})
CREATE (n14 {id: 14, k2: 14, k4: 'x', k3: false, k10: ['x']})
CREATE (n15 {id: 15, k2: 15, k4: 'x', k3: false, k10: ['x']})
CREATE (n16 {id: 16, k2: 16, k4: 'x', k3: false, k10: ['x']})
CREATE (n17 {id: 17, k2: 17, k4: 'x', k3: false, k10: ['x']})
CREATE (n18 {id: 18, k2: 18, k4: 'x', k3: false, k10: ['x']})
RETURN 1 AS ok
$$) AS (ok agtype);
```
### 2. Target query
Run this exact statement twice:
```sql
SELECT count(*)
FROM (
SELECT *
FROM ag_catalog.cypher('age_issue21_repeated_write', $$
MATCH (n0)
SET n0.k3 = FALSE
REMOVE n0.k10
WITH n0.id AS alias0,
abs(toFloat(n0.k2)) AS alias1,
CASE WHEN n0.k4 IS NOT NULL THEN n0.k4 ELSE '' END AS alias2,
n0
WHERE true
DETACH DELETE n0
CREATE (n1 {id: 100})
RETURN {alias0: alias0, alias1: alias1, alias2: alias2, n1: n1} AS row
$$) AS (row agtype)
) AS counted;
```
### 3. Fresh post-state control
The first target execution deletes the 19 original nodes and creates 19 nodes
with only `id: 100`. To rule out an ordinary input-data difference, create
that expected post-state directly on a separate fresh graph and run the target
query once there. Use the same target query with graph name
`age_issue21_post_state_control`.
```sql
LOAD 'age';
SET search_path = ag_catalog, public;
SELECT ag_catalog.create_graph('age_issue21_post_state_control');
SELECT *
FROM ag_catalog.cypher('age_issue21_post_state_control', $$
CREATE (n0 {id: 100})
CREATE (n1 {id: 100})
CREATE (n2 {id: 100})
CREATE (n3 {id: 100})
CREATE (n4 {id: 100})
CREATE (n5 {id: 100})
CREATE (n6 {id: 100})
CREATE (n7 {id: 100})
CREATE (n8 {id: 100})
CREATE (n9 {id: 100})
CREATE (n10 {id: 100})
CREATE (n11 {id: 100})
CREATE (n12 {id: 100})
CREATE (n13 {id: 100})
CREATE (n14 {id: 100})
CREATE (n15 {id: 100})
CREATE (n16 {id: 100})
CREATE (n17 {id: 100})
CREATE (n18 {id: 100})
RETURN 1 AS ok
$$) AS (ok agtype);
```
Run the target query once against this control graph. It returns `19`.
## Expected result
The first and second executions on the reproduction graph should both return
`19`. The fresh post-state control should also return `19`.
The query matches 19 nodes. `SET`, `REMOVE`, `WITH`, `WHERE true`,
`DETACH DELETE`, and the per-row `CREATE` do not introduce an `UNWIND`, a
Cartesian product, or another row-producing source.
## Actual result
On the stated environment, the reproduction graph returns:
```text
first execution: 19
second execution: 36
```
The separate fresh post-state graph returns `19` on its first execution. After
both executions on the reproduction graph, the following control still
returns `19`:
```sql
SELECT *
FROM ag_catalog.cypher('age_issue21_repeated_write', $$
MATCH (n) RETURN count(n) AS node_count
$$) AS (node_count agtype);
```
Therefore the 36 rows are not explained by the expected graph transition:
the same post-state returns 19 when constructed fresh. The second destructive
execution on the original graph returns an incorrect number of result rows.
## Controls
- Running the target query once on the original fresh graph returns `19`.
- Constructing the expected post-state directly on a separate fresh graph and
running the target query once returns `19`.
- Only the transition from the first committed destructive write to the next
write produces `19 -> 36` in this reproduction.
- Running it twice with `WITH *` inserted after `CREATE` also produces the
second-execution cardinality error.
- Replacing that boundary with an explicit projection produces the same error.
- Removing the final boundary entirely still reproduces the error.
- In this exact query shape, 1--18 input nodes did not reproduce the issue;
19 nodes is the smallest tested input.
- The same family was also observed with rollback (`21 -> 41`), but rollback
is not required for reproduction.
Contributor guide
Research direction
Start at the ag_catalog.cypher execution path and reproduce the two committed executions using the SQL in the issue. Compare row cardinality with the fresh post-state control and inspect how the first destructive write affects the next statement. Done means the reproduction graph returns 19 on both runs, matching the control, without relying on rollback.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, postgresql
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 50/100