Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table

From: Manuel Reyes Bravo <manuelreyesbravo(at)gmail(dot)com>
To: Jakub Wartak <jakub(dot)wartak(at)enterprisedb(dot)com>
Cc: Ajit Awekar <ajitpostgres(at)gmail(dot)com>, Etsuro Fujita <etsuro(dot)fujita(at)gmail(dot)com>, Michael Paquier <michael(at)paquier(dot)xyz>, shihao zhong <zhong950419(at)gmail(dot)com>, Nikita Malakhov <hukutoc(at)gmail(dot)com>, Jehan-Guillaume de Rorthais <jgdr(at)dalibo(dot)com>, pgsql-hackers(at)lists(dot)postgresql(dot)org
Subject: Re: [(known) BUG] DELETE/UPDATE more than one row in partitioned foreign table
Date: 2026-09-16 13:15:44
Message-ID: CA+bCEdCo-w5_34AAm6BTZ0PucNg3sycpM8-7L6Qn_KCwxSR30Q@mail.gmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Ajit Awekar <ajitpostgres(at)gmail(dot)com> wrote:
> Verified against your repros plus the full regression.

I tested v2 (cfbot branch for CF 6770, on master at 862092932c9) against
the same master without it, both with --enable-cassert, using a loopback
server. The attached run_cases.sh runs each case file on a fresh
cluster.

What v2 fixes
-------------

- Etsuro's DELETE ... USING case: master deletes 20 rows instead of 10;
with v2, exactly the 10 matching rows.

- The same corruption through triggers (case_d): remote partitioned
table, foreign table with a dropped column in the middle, a column
mapped with column_name, a different column order, a BEFORE UPDATE
trigger changing NEW and an AFTER UPDATE trigger reading OLD. On
master, updating ids 1 and 2 leaves two rows with id 2 and none with
id 1. With v2 both rows are right, and OLD and NEW seen by the
triggers are right, so the local whole-row reconstruction handles the
dropped and renamed columns.

- A local partitioned table with one local and one foreign partition,
UPDATE and DELETE with RETURNING tableoid and the whole row (case_g),
and a self-join UPDATE (case_h): wrong rows on master, right with v2.

Jakub's zero-column case with a BEFORE DELETE trigger still fails the
assertion (postgres_fdw.c:9281 on this tree), while master runs it
fine. Without the trigger, DELETE ... RETURNING ft1 on the same table
works on both.

What v2 does not fix: chained foreign tables
--------------------------------------------

When a foreign table points to another foreign table, which points to
the partitioned table, the wrong rows are still updated and deleted,
exactly as on master (case_f):

CREATE TABLE r (id int, grp int, v text) PARTITION BY LIST (grp);
CREATE TABLE r1 PARTITION OF r FOR VALUES IN (1);
CREATE TABLE r2 PARTITION OF r FOR VALUES IN (2);
INSERT INTO r VALUES (1, 1, 'one'), (2, 2, 'two');
CREATE FOREIGN TABLE ft_mid (id int, grp int, v text)
SERVER loopback OPTIONS (table_name 'r');
CREATE FOREIGN TABLE ft_outer (id int, grp int, v text)
SERVER loopback OPTIONS (table_name 'ft_mid');

UPDATE ft_outer SET v = v || '!' WHERE id = 1 AND random() <= 1;

SELECT tableoid::regclass, ctid, * FROM r ORDER BY id;
tableoid | ctid | id | grp | v
----------+-------+----+-----+------
r1 | (0,2) | 1 | 1 | one!
r2 | (0,2) | 2 | 2 | one!

With log_statement = all the cause is visible. The outer hop sends

UPDATE public.ft_mid SET v = $3 WHERE ctid = $1 AND tableoid = $2
Parameters: $1 = '(0,1)', $2 = '16408', $3 = 'one!'

where 16408 is ft_mid itself, so the tableoid condition matches every
row of ft_mid. The middle hop then finds (0,1) in both partitions and
sends two updates, one per remote tableoid:

UPDATE public.r SET v = $3 WHERE ctid = $1 AND tableoid = $2
Parameters: $1 = '(0,1)', $2 = '16398', $3 = 'one!'
UPDATE public.r SET v = $3 WHERE ctid = $1 AND tableoid = $2
Parameters: $1 = '(0,1)', $2 = '16403', $3 = 'one!'

In the same case, DELETE ... WHERE id = 3 also deletes id 4.

So once the remote table is itself a foreign table, (ctid, tableoid)
is no longer a row identity. I don't see how the outer hop could fix
that without knowing the remote relkind. Maybe that is worth a
sentence next to the new partitioning note in postgres-fdw.sgml, or a
check of the remote relkind that refuses the non-direct path for it,
but I would rather ask what you think than propose one. The EXPLAIN
output in this case now shows "WHERE ctid = $1 AND tableoid = $2",
which makes it look protected when it is not.

Regards,
Manu

Attachment Content-Type Size
run_cases.sh application/x-shellscript 908 bytes
case_f_chained_fdw.sql application/sql 1011 bytes
case_d_dropped_and_renamed_columns.sql application/sql 1.5 KB
case_g_mixed_local_foreign_partitions.sql application/sql 1.2 KB
case_h_self_join.sql application/sql 658 bytes

In response to

Browse pgsql-hackers by date

  From Date Subject
Next Message Manuel Reyes Bravo 2026-09-16 13:20:45 Add a test for index_rebuild_count of REPACK (CONCURRENTLY)
Previous Message Daniel Gustafsson 2026-09-16 13:04:36 Re: pgsql: Revert online data checksum transitions