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

From: Etsuro Fujita <[email-masked]>
Date: Mon, 1 Jun 2026 19:44:34 +0900

Hi Nikita,

On Fri, May 15, 2026 at 2:23 AM Nikita Malakhov <[email-masked]> wrote:
> CFbot was unhappy with previous patch set, so here's updated one

Thanks for working on this issue!

I took a quick look at the patch set.  IIUC I think it's created based
on what I proposed in the original thread, which is invasive and thus
not back-patchable, so what you are proposing here isn't
back-patchable, either, I think.

I think we should first work on a back-patchable solution.  So I'd
like to re-propose the patch that I proposed in this thread before to
disallow UPDATE/DELETE in problematic cases [1].  Attached is a new
version of the patch.  Changes are:

* Renamed the new table option inherited to remotely_inherited, to
avoid confusion with local inheritance.
* Moved the logic to prevent problematic UPDATE/DELETE from a planner
function to an executor function, to avoid throwing an error
unnecessarily when there are no target rows to update/delete.
* Added docs to postgres-fdw.sgml.

I'm planning to add the postgresImportForeignSchema() support in the
next version.

I think the remotely_inherited option would be useful when adding the
support for the UPDATE/DELETE, as it could be used to address one of
Tom Lane's comments about what I proposed in the original thread that
it adds the tabloid condition to a remote UPDATE/DELETE query whether
the target table is inherited or not: that could be avoid if the
option is set to false.

What do you think about that?

Best regards,
Etsuro Fujita

[1] https://www.postgresql.org/message-id/CAPmGK15CQK-oYFMAyq%2BrR0rQapUHtvAGuGgY5ahERHzZ4tmC8g%40mail.gmail.com

---

From: Etsuro Fujita <[email-masked]>
Date: Fri, 5 Jun 2026 20:59:17 +0900

> I'm planning to add the postgresImportForeignSchema() support in the

I created the patch to add that support on top of the patch I sent in
a previous email, which I'm attaching along with the base patch.  It's
the same as before, except that I fixed a typo in docs pointed out by
Michael-san off-list.

Comments welcome!

Best regards,
Etsuro Fujita

---

From: Nikita Malakhov <[email-masked]>
Date: Sat, 6 Jun 2026 23:33:32 +0300

Hi!

Thanks for working on the subject! I'll try to take a look inside in a
couple of days.

-- 
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/

---

From: Etsuro Fujita <[email-masked]>
Date: Mon, 8 Jun 2026 21:45:03 +0900

Hi Nikita,

On Sun, Jun 7, 2026 at 5:33 AM Nikita Malakhov <[email-masked]> wrote:
> Thanks for working on the subject! I'll try to take a look inside in a couple of days.

Great!

Best regards,
Etsuro Fujita

---

From: Michael Paquier <[email-masked]>
Date: Wed, 10 Jun 2026 14:37:57 +0900

> the same as before, except that I fixed a typo in docs pointed out by

Splitting the patch set into two pieces, as of one for the
introduction of the remotely_inherited option defaulting to the
current HEAD behavior, and one for the modification of the IMPORT
FOREIGN SCHEMA, makes sense here.  A backpatch of the first patch is a
no-brainer, so as it gives a way for users to switch to the new
behavior at will.  I am however on edge regarding the wisdom of
backpatching the second patch, which would force a new behavior of the
postgres_fdw implementation for partitioned tables (based on my
read of the test with "t4") and INHERIT ("t6", "t8") depending on the
relkind or the property of the relation imported.  I can't help but
wonder why you don't take a different, slightly more conservative
approach on HEAD and the stable branches with a new option that can be
specified to the IMPORT FOREIGN SCHEMA query, to make the choice of
setting remotely_inherited for a relation imported an opt-in or
opt-out choice.

I would not object with a switch of the default behavior across major
versions, and perhaps my argument is not sound enough, but I've learnt
my share when it comes to be careful with changes like the one you may
introduce here across a minor release, particularly knowing that
remotely_inherited *can* be set on an option basis when creating a
table *or* when importing a schema.  The designs we have for these
queries allows this kind of flexibility.
--
Michael

---

From: Etsuro Fujita <[email-masked]>
Date: Wed, 10 Jun 2026 20:22:31 +0900

> queries allows this kind of flexibility.

I agree that we should take a more conservative approach especially on
the stable branches, and I think it's a good idea to add the option to
IMPORT FOREIGN SCHEMA for that, so I will update the patch as such in
the next version.

Thanks for the comments!

Best regards,
Etsuro Fujita

---

From: Etsuro Fujita <[email-masked]>
Date: Wed, 10 Jun 2026 20:30:46 +0900

> back-patchable, either, I think.

One thing I noticed about what I proposed in the original thread (but
didn't when working on it) is that it would well handle cases where
the remote table is a (simple) inherited/partitioned table, but
wouldn't cases where it's e.g., a foreign table on the remote server
pointing to such a table on another remote server.  I haven't looked
at your patch in very detail yet, but I tested it as shown below, and
it causes unexpected results, so I suppose it inherits the limitation.

create table pt (a int, b text) partition by list (a);
create table pt_p1 partition of pt for values in (1);
create table pt_p2 partition of pt for values in (2);
create foreign table ft1 (a int, b text) server loopback options
(table_name 'pt');
create foreign table ft2 (a int, b text) server loopback options
(table_name 'ft1');
insert into pt values (1, 'foo'), (2, 'bar');
select ctid, * from ft2;
 ctid  | a |  b
-------+---+-----
 (0,1) | 1 | foo
 (0,1) | 2 | bar
(2 rows)

explain verbose update ft2 set b = b || b where b = 'bar' and random() < 1.0;
                                           QUERY PLAN
------------------------------------------------------------------------------------------------
 Update on public.ft2  (cost=100.00..121.66 rows=0 width=0)
   Remote SQL: UPDATE public.ft1 SET b = $3 WHERE ctid = $1 AND tableoid = $2
   ->  Foreign Scan on public.ft2  (cost=100.00..121.66 rows=1 width=106)
         Output: (b || b), ctid, tableoid, $0, ft2.*
         Filter: (random() < '1'::double precision)
         Remote SQL: SELECT a, b, ctid, tableoid FROM public.ft1 WHERE
((b = 'bar')) FOR UPDATE
(6 rows)

update ft2 set b = b || b where b = 'bar' and random() < 1.0;
UPDATE 1
select ctid, * from ft2;
 ctid  | a |   b
-------+---+--------
 (0,2) | 1 | barbar
 (0,1) | 2 | bar
(2 rows)

The first row belonging to pt_p1 is updated, which is wrong; the
second one belonging to pt_p2 should be updated.

To address this, I think it would be good if we could 1) extend the
concept of inheritance to cover remote inheritances, like pt, and 2)
extend inherited UPDATE/DELETE so that we update/delete leaf tables,
like pt_p2, somehow directly, as done for local inheritances.  I'm not
sure about how to do that, though.

Best regards,
Etsuro Fujita

---

From: Michael Paquier <[email-masked]>
Date: Thu, 11 Jun 2026 07:25:28 +0900

> IMPORT FOREIGN SCHEMA for that, so I will update the patch as such in

Cool, thanks.
--
Michael

---

From: Michael Paquier <[email-masked]>
Date: Thu, 11 Jun 2026 07:30:42 +0900

> sure about how to do that, though.

FWIW, I think that there is a good argument for keeping it down to
simpler, and just not care about the option chain in such cases,
leaving it up to users to address that with two imports anyway?  Just 
having the option at one level would solve most historical problems I
could see on this thread.  Good is sometimes a better option than
theoretically perfect.  And good here means a simpler implementation
overall, at least it seems to me so..
--
Michael

---

From: Nikita Malakhov <[email-masked]>
Date: Sat, 13 Jun 2026 22:43:17 +0300

Hi!

While testing the proposed solution we've stumbled upon another vanilla bug
related to FDW -
a query with DELETE ... USING selects invalid records from partitioned FDW
tables:

CREATE EXTENSION postgres_fdw;

CREATE SERVER testserver1 FOREIGN DATA WRAPPER postgres_fdw;
DO $d$
    BEGIN
        EXECUTE $$CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw
            OPTIONS (dbname '$$||current_database()||$$',
                     port '$$||current_setting('port')||$$'
            )$$;
    END;
$d$;

CREATE USER MAPPING FOR public SERVER testserver1
OPTIONS (user 'value', password 'value');
CREATE USER MAPPING FOR CURRENT_USER SERVER loopback;
CREATE USER MAPPING FOR CURRENT_USER SERVER loopback2;
CREATE USER MAPPING FOR public SERVER loopback3;

CREATE TABLE acc_entry
(
    id bigint,
    doc_date date,
    impact int,
    amount numeric
) PARTITION BY RANGE (doc_date);

CREATE TABLE acc_entry_p1
PARTITION OF acc_entry
FOR VALUES FROM ('2025-01-01') TO ('2025-07-01');

CREATE TABLE acc_entry_p2
PARTITION OF acc_entry
FOR VALUES FROM ('2025-07-01') TO ('2026-01-01');

CREATE FOREIGN TABLE measurement_fdw
(
    id bigint,
    doc_date date,
    impact int,
    amount numeric
)
SERVER loopback
OPTIONS (table_name 'acc_entry');

INSERT INTO acc_entry
SELECT
    CASE
        WHEN g IN (4,15,26,35,46,55,66,75,86,95)
            THEN 2501020100000124
        ELSE g
    END AS id,
    CASE WHEN g % 2 = 0 THEN timestamp '2025-02-02' ELSE timestamp
'2025-08-08' END,
    1,
    g
FROM generate_series(1,100) g;

DELETE FROM measurement_fdw
USING (
    SELECT id
    FROM measurement_fdw
    WHERE id = 2501020100000124
    LIMIT 1
) s
WHERE measurement_fdw.id = s.id;

The latter query selects and deletes records with invalid ID which should
not be selected at all.
Although rewritten query like
with sub as (
           select t1.id sub_id
           from measurement_fdw t1
           where t1.id=2501020100000124
           limit 1
        )
select m.id, m.doc_date, m.impact, m.amount from measurement_fdw m, sub
        where m.id = sub.sub_id;
works correctly.
Currently I try to figure out what's the cause of this strange behavior and
I'm suspicious about
/*
 * WCO_RLS_MERGE_DELETE_CHECK is used to check DELETE USING quals on
 * the existing target row.
*/
add_with_check_options(rel, rt_index,
                    WCO_RLS_MERGE_DELETE_CHECK,
merge_delete_permissive_policies,
merge_delete_restrictive_policies,
withCheckOptions,
hasSubLinks,
hasSubLinks,
true);

---

From: Etsuro Fujita <[email-masked]>
Date: Mon, 15 Jun 2026 23:54:55 +0900

On Sun, Jun 14, 2026 at 4:43 AM Nikita Malakhov <[email-masked]> wrote:
> While testing the proposed solution we've stumbled upon another vanilla bug related to FDW -
> a query with DELETE ... USING selects invalid records from partitioned FDW tables:

>     CASE WHEN g % 2 = 0 THEN timestamp '2025-02-02' ELSE timestamp '2025-08-08' END,
>     1,
>     g
> The latter query selects and deletes records with invalid ID which should not be selected at all.

I think that that would be another example that the bug discussed here
causes unexpected results, as I have this after inserting the data
into the partitioned table:

select tableoid::regclass, ctid, * from acc_entry where ctid in
(select ctid from acc_entry where id = 2501020100000124);
   tableoid   |  ctid  |        id        |  doc_date  | impact | amount
--------------+--------+------------------+------------+--------+--------
 acc_entry_p1 | (0,2)  | 2501020100000124 | 2025-02-02 |      1 |      4
 acc_entry_p1 | (0,8)  |               16 | 2025-02-02 |      1 |     16
 acc_entry_p1 | (0,13) | 2501020100000124 | 2025-02-02 |      1 |     26
 acc_entry_p1 | (0,18) |               36 | 2025-02-02 |      1 |     36
 acc_entry_p1 | (0,23) | 2501020100000124 | 2025-02-02 |      1 |     46
 acc_entry_p1 | (0,28) |               56 | 2025-02-02 |      1 |     56
 acc_entry_p1 | (0,33) | 2501020100000124 | 2025-02-02 |      1 |     66
 acc_entry_p1 | (0,38) |               76 | 2025-02-02 |      1 |     76
 acc_entry_p1 | (0,43) | 2501020100000124 | 2025-02-02 |      1 |     86
 acc_entry_p1 | (0,48) |               96 | 2025-02-02 |      1 |     96
 acc_entry_p2 | (0,2)  |                3 | 2025-08-08 |      1 |      3
 acc_entry_p2 | (0,8)  | 2501020100000124 | 2025-08-08 |      1 |     15
 acc_entry_p2 | (0,13) |               25 | 2025-08-08 |      1 |     25
 acc_entry_p2 | (0,18) | 2501020100000124 | 2025-08-08 |      1 |     35
 acc_entry_p2 | (0,23) |               45 | 2025-08-08 |      1 |     45
 acc_entry_p2 | (0,28) | 2501020100000124 | 2025-08-08 |      1 |     55
 acc_entry_p2 | (0,33) |               65 | 2025-08-08 |      1 |     65
 acc_entry_p2 | (0,38) | 2501020100000124 | 2025-08-08 |      1 |     75
 acc_entry_p2 | (0,43) |               85 | 2025-08-08 |      1 |     85
 acc_entry_p2 | (0,48) | 2501020100000124 | 2025-08-08 |      1 |     95
(20 rows)

Note that the rows with normal ids have the same ctid as the rows with
id=2501020100000124 (for example, ctid of the row with id=3 is (0,2),
which is the same as that of the first row, which has
id=2501020100000124), so the bug would delete such normal-id rows as
well when performing the delete query.

Best regards,
Etsuro Fujita

---

From: Nikita Malakhov <[email-masked]>
Date: Thu, 9 Jul 2026 17:03:58 +0300

Hi hackers!

I've fixed the issue mentioned in two previous messages, so for anyone
already have taken
my patch set above I'd compiled a fixed patch set. It does not object an
approach
proposed by Etsuro-san.

The first patch file
v4-0001-copy-and-remote-tableoid-param.patch
is a core patch introducing extended copy slot and tuple facilities
and core machinery for remote table OIDs;

The second file
v4-0002-teach-fdw-use-remote-tableoid.patch
is a fdw facilities changes (fixed) to use remote table OID.