agora inbox for [email protected]help / color / mirror / Atom feed
[PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server 814+ messages / 2 participants [nested] [flat]
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server @ 2025-07-18 14:52 Jehan-Guillaume de Rorthais <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Jehan-Guillaume de Rorthais @ 2025-07-18 14:52 UTC (permalink / raw) When a foreign table points to a partitioned table or an inheritance parent on the foreign server, a non-direct DML can affect multiple rows when only one row is intended to be affected. This happens because postgres_fdw uses only ctid to identify a row to work on. Though ctid uniquely identifies a row in a single table, in a partitioned table or in an inheritance hierarchy, there can be be multiple rows, in different partitions, with the same ctid. So a DML statement sent to the foreign server by postgres_fdw ends up affecting more than one rows, only one of which is intended to be affected. In such a case it's good to throw an error instead of corrupting remote database with unwanted UPDATE/DELETEs. Subsequent commits will try to fix this situation. Author: Ashutosh Bapat <[email protected]> Author: Kyotaro Horiguchi <[email protected]> Rebased by Jehan-Guillaume de Rorthais <[email protected]> --- .../postgres_fdw/expected/postgres_fdw.out | 26 ++++++++------ contrib/postgres_fdw/postgres_fdw.c | 36 +++++++++++++++---- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index 62019eaa881..b0ef54a2889 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -8984,10 +8984,11 @@ UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa (5 rows) UPDATE fa SET aa = (CASE WHEN random() <= 1 THEN 'zzzz' ELSE NULL END) WHERE aa = 'aaa'; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa -----------+-------+------ - fa | (0,2) | zzzz + tableoid | ctid | aa +----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb (2 rows) @@ -9008,11 +9009,13 @@ DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); (6 rows) DELETE FROM fa WHERE aa = (CASE WHEN random() <= 1 THEN 'aaa' ELSE 'bbb' END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fa; - tableoid | ctid | aa + tableoid | ctid | aa ----------+-------+----- + fa | (0,1) | aaa fa | (0,1) | bbb -(1 row) +(2 rows) -- cleanup DROP FOREIGN TABLE fa; @@ -9048,10 +9051,11 @@ UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; (5 rows) UPDATE fplt SET b = (CASE WHEN random() <= 1 THEN 10 ELSE 20 END) WHERE a = 1; +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; - tableoid | ctid | a | b -----------+-------+---+---- - fplt | (0,2) | 1 | 10 + tableoid | ctid | a | b +----------+-------+---+--- + fplt | (0,1) | 1 | 1 fplt | (0,1) | 2 | 2 (2 rows) @@ -9071,11 +9075,13 @@ DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); (6 rows) DELETE FROM fplt WHERE a = (CASE WHEN random() <= 1 THEN 1 ELSE 10 END); +ERROR: foreign server affected 2 rows when only one was expected SELECT tableoid::regclass, ctid, * FROM fplt; tableoid | ctid | a | b ----------+-------+---+--- - fplt | (0,1) | 2 | 2 -(1 row) + fplt | (0,1) | 1 | 1 + fplt | (0,1) | 2 | 2 +(2 rows) DROP TABLE plt; DROP FOREIGN TABLE fplt; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index e0a34b27c7c..09c87d0e5d8 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -4132,7 +4132,8 @@ execute_foreign_modify(EState *estate, ItemPointer ctid = NULL; const char **p_values; PGresult *res; - int n_rows; + int n_rows_returned; + int n_rows_affected; StringInfoData sql; /* The operation should be INSERT, UPDATE, or DELETE */ @@ -4213,27 +4214,50 @@ execute_foreign_modify(EState *estate, pgfdw_report_error(ERROR, res, fmstate->conn, true, fmstate->query); /* Check number of rows affected, and fetch RETURNING tuple if any */ + n_rows_affected = atoi(PQcmdTuples(res)); if (fmstate->has_returning) { Assert(*numSlots == 1); - n_rows = PQntuples(res); - if (n_rows > 0) + n_rows_returned = PQntuples(res); + if (n_rows_returned > 0) store_returning_result(fmstate, slots[0], res); + + // FIXME: shouldn't we check the max number of rows returned is one? } else - n_rows = atoi(PQcmdTuples(res)); + n_rows_returned = 0; /* And clean up */ PQclear(res); MemoryContextReset(fmstate->temp_cxt); - *numSlots = n_rows; + /* + * UPDATE & DELETE command can only affect one row, make sure this contract + * is respected. + * CMD_INSERT can insert multiple row when called from ForeignBatchInsert. + */ + if (operation != CMD_INSERT) + { + /* No rows should be returned if no rows were affected */ + if (n_rows_affected == 0 && n_rows_returned != 0) + elog(ERROR, "foreign server returned %d rows when no row was affected", + n_rows_returned); + + /* ERROR if more than one row was updated on the remote end */ + if (n_rows_affected > 1) + ereport(ERROR, + (errcode (ERRCODE_FDW_ERROR), /* XXX */ + errmsg ("foreign server affected %d rows when only one was expected", + n_rows_affected))); + } + + *numSlots = n_rows_returned; /* * Return NULL if nothing was inserted/updated/deleted on the remote end */ - return (n_rows > 0) ? slots : NULL; + return (n_rows_affected > 0) ? slots : NULL; } /* -- 2.50.0 --MP_/4ZRYdF7Ah.pt5w65PuZdaPz-- ^ permalink raw reply [nested|flat] 814+ messages in thread
* [PATCH v2] Track skipped tables during vacuum and analyze @ 2026-03-24 04:09 Yugo Nagata <[email protected]> 0 siblings, 0 replies; 814+ messages in thread From: Yugo Nagata @ 2026-03-24 04:09 UTC (permalink / raw) --- src/backend/catalog/system_views.sql | 8 ++ src/backend/commands/vacuum.c | 84 +++++++++++++++----- src/backend/utils/activity/pgstat_relation.c | 69 ++++++++++++++++ src/backend/utils/adt/pgstatfuncs.c | 24 ++++++ src/include/catalog/pg_proc.dat | 32 ++++++++ src/include/pgstat.h | 11 +++ src/test/regress/expected/rules.out | 24 ++++++ 7 files changed, 230 insertions(+), 22 deletions(-) diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql index e54018004db..2ca02a0354c 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -736,13 +736,21 @@ CREATE VIEW pg_stat_all_tables AS pg_stat_get_mod_since_analyze(C.oid) AS n_mod_since_analyze, pg_stat_get_ins_since_vacuum(C.oid) AS n_ins_since_vacuum, pg_stat_get_last_vacuum_time(C.oid) as last_vacuum, + pg_stat_get_last_skipped_vacuum_time(C.oid) as last_skipped_vacuum, pg_stat_get_last_autovacuum_time(C.oid) as last_autovacuum, + pg_stat_get_last_skipped_autovacuum_time(C.oid) as last_skipped_autovacuum, pg_stat_get_last_analyze_time(C.oid) as last_analyze, + pg_stat_get_last_skipped_analyze_time(C.oid) as last_skipped_analyze, pg_stat_get_last_autoanalyze_time(C.oid) as last_autoanalyze, + pg_stat_get_last_skipped_autoanalyze_time(C.oid) as last_skipped_autoanalyze, pg_stat_get_vacuum_count(C.oid) AS vacuum_count, + pg_stat_get_skipped_vacuum_count(C.oid) AS skipped_vacuum_count, pg_stat_get_autovacuum_count(C.oid) AS autovacuum_count, + pg_stat_get_skipped_autovacuum_count(C.oid) AS skipped_autovacuum_count, pg_stat_get_analyze_count(C.oid) AS analyze_count, + pg_stat_get_skipped_analyze_count(C.oid) AS skipped_analyze_count, pg_stat_get_autoanalyze_count(C.oid) AS autoanalyze_count, + pg_stat_get_skipped_autoanalyze_count(C.oid) AS skipped_autoanalyze_count, pg_stat_get_total_vacuum_time(C.oid) AS total_vacuum_time, pg_stat_get_total_autovacuum_time(C.oid) AS total_autovacuum_time, pg_stat_get_total_analyze_time(C.oid) AS total_analyze_time, diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index bce3a2daa24..8125dfac137 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -794,6 +794,11 @@ vacuum_open_relation(Oid relid, RangeVar *relation, bits32 options, { rel = NULL; rel_lock = false; + + pgstat_report_skipped_vacuum_analyze(relid, + (options & VACOPT_VACUUM) != 0, + (options & VACOPT_ANALYZE) != 0, + AmAutoVacuumWorkerProcess()); } /* if relation is opened, leave */ @@ -904,7 +909,6 @@ expand_vacuum_rel(VacuumRelation *vrel, MemoryContext vac_context, Form_pg_class classForm; bool include_children; bool is_partitioned_table; - int rvr_opts; /* * Since autovacuum workers supply OIDs when calling vacuum(), no @@ -917,29 +921,65 @@ expand_vacuum_rel(VacuumRelation *vrel, MemoryContext vac_context, * below, as well as find_all_inheritors's expectation that the caller * holds some lock on the starting relation. */ - rvr_opts = (options & VACOPT_SKIP_LOCKED) ? RVR_SKIP_LOCKED : 0; - relid = RangeVarGetRelidExtended(vrel->relation, - AccessShareLock, - rvr_opts, - NULL, NULL); - - /* - * If the lock is unavailable, emit the same log statement that - * vacuum_rel() and analyze_rel() would. - */ - if (!OidIsValid(relid)) + if (!(options & VACOPT_SKIP_LOCKED)) { - if (options & VACOPT_VACUUM) - ereport(WARNING, - (errcode(ERRCODE_LOCK_NOT_AVAILABLE), - errmsg("skipping vacuum of \"%s\" --- lock not available", - vrel->relation->relname))); - else - ereport(WARNING, - (errcode(ERRCODE_LOCK_NOT_AVAILABLE), - errmsg("skipping analyze of \"%s\" --- lock not available", + relid = RangeVarGetRelidExtended(vrel->relation, + AccessShareLock, + 0, NULL, NULL); + if (!OidIsValid(relid)) + return vacrels; + } + else + { + /* Get relid for reporting before taking a lock */ + relid = RangeVarGetRelid(vrel->relation, NoLock, false); + + if (!ConditionalLockRelationOid(relid, AccessShareLock)) + { + /* + * If the lock is unavailable, emit the same log statement that + * vacuum_rel() and analyze_rel() would. + */ + if (options & VACOPT_VACUUM) + ereport(WARNING, + (errcode(ERRCODE_LOCK_NOT_AVAILABLE), + errmsg("skipping vacuum of \"%s\" --- lock not available", vrel->relation->relname))); - return vacrels; + else + ereport(WARNING, + (errcode(ERRCODE_LOCK_NOT_AVAILABLE), + errmsg("skipping analyze of \"%s\" --- lock not available", + vrel->relation->relname))); + + pgstat_report_skipped_vacuum_analyze(relid, + (options & VACOPT_VACUUM) != 0, + (options & VACOPT_ANALYZE) != 0, + false); + return vacrels; + } + + /* + * Now that we have the lock, probe to see if the relation really + * exists or not. + */ + if (!SearchSysCacheExists1(RELOID, ObjectIdGetDatum(relid))) + { + if (options & VACOPT_VACUUM) + ereport(WARNING, + (errcode(ERRCODE_UNDEFINED_TABLE), + errmsg("skipping vacuum of \"%s\" --- relation no longer exists", + vrel->relation->relname))); + else + ereport(WARNING, + (errcode(ERRCODE_UNDEFINED_TABLE), + errmsg("skipping analyze of \"%s\" --- relation no longer exists", + vrel->relation->relname))); + + /* Release useless lock */ + UnlockRelationOid(relid, AccessShareLock); + + return vacrels; + } } /* diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c index bc8c43b96aa..51d0f280ebf 100644 --- a/src/backend/utils/activity/pgstat_relation.c +++ b/src/backend/utils/activity/pgstat_relation.c @@ -17,6 +17,7 @@ #include "postgres.h" +#include "access/htup_details.h" #include "access/twophase_rmgr.h" #include "access/xact.h" #include "catalog/catalog.h" @@ -366,6 +367,74 @@ pgstat_report_analyze(Relation rel, (void) pgstat_flush_backend(false, PGSTAT_BACKEND_FLUSH_IO); } +/* + * Report that the table was skipped during vacuum or/and analyze. + */ +void +pgstat_report_skipped_vacuum_analyze(Oid relid, bool vacuum, bool analyze, + bool autovacuum) +{ + PgStat_EntryRef *entry_ref; + PgStatShared_Relation *shtabentry; + PgStat_StatTabEntry *tabentry; + TimestampTz ts; + HeapTuple classTup; + bool isshared; + + if (!pgstat_track_counts || !(vacuum || analyze)) + return; + + classTup = SearchSysCache1(RELOID, ObjectIdGetDatum(relid)); + if (!HeapTupleIsValid(classTup)) + return; /* somebody deleted the rel, forget it */ + isshared = ((Form_pg_class) GETSTRUCT(classTup))->relisshared; + ReleaseSysCache(classTup); + + /* Store the data in the table's hash table entry. */ + ts = GetCurrentTimestamp(); + + /* block acquiring lock for the same reason as pgstat_report_autovac() */ + entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_RELATION, + isshared ? InvalidOid : MyDatabaseId, + relid, false); + + shtabentry = (PgStatShared_Relation *) entry_ref->shared_stats; + tabentry = &shtabentry->stats; + + if (autovacuum) + { + if (vacuum) + { + tabentry->last_skipped_autovacuum_time = ts; + tabentry->skipped_autovacuum_count++; + } + if (analyze) + { + tabentry->last_skipped_autoanalyze_time = ts; + tabentry->skipped_autoanalyze_count++; + } + } + else + { + if (vacuum) + { + tabentry->last_skipped_vacuum_time = ts; + tabentry->skipped_vacuum_count++; + } + if (analyze) + { + tabentry->last_skipped_analyze_time = ts; + tabentry->skipped_analyze_count++; + } + } + + pgstat_unlock_entry(entry_ref); + + /* see pgstat_report_vacuum() */ + pgstat_flush_io(false); + (void) pgstat_flush_backend(false, PGSTAT_BACKEND_FLUSH_IO); +} + /* * count a tuple insertion of n tuples */ diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c index 9185a8e6b83..18aaa3996e1 100644 --- a/src/backend/utils/adt/pgstatfuncs.c +++ b/src/backend/utils/adt/pgstatfuncs.c @@ -84,6 +84,18 @@ PG_STAT_GET_RELENTRY_INT64(mod_since_analyze) /* pg_stat_get_numscans */ PG_STAT_GET_RELENTRY_INT64(numscans) +/* pg_stat_get_skipped_analyze_count */ +PG_STAT_GET_RELENTRY_INT64(skipped_analyze_count) + +/* pg_stat_get_skipped_autoanalyze_count */ +PG_STAT_GET_RELENTRY_INT64(skipped_autoanalyze_count) + +/* pg_stat_get_skipped_autovacuum_count */ +PG_STAT_GET_RELENTRY_INT64(skipped_autovacuum_count) + +/* pg_stat_get_skipped_vacuum_count */ +PG_STAT_GET_RELENTRY_INT64(skipped_vacuum_count) + /* pg_stat_get_tuples_deleted */ PG_STAT_GET_RELENTRY_INT64(tuples_deleted) @@ -170,6 +182,18 @@ PG_STAT_GET_RELENTRY_TIMESTAMPTZ(last_vacuum_time) /* pg_stat_get_lastscan */ PG_STAT_GET_RELENTRY_TIMESTAMPTZ(lastscan) +/* pg_stat_get_last_skipped_analyze_time */ +PG_STAT_GET_RELENTRY_TIMESTAMPTZ(last_skipped_analyze_time) + +/* pg_stat_get_last_skipped_autoanalyze_time */ +PG_STAT_GET_RELENTRY_TIMESTAMPTZ(last_skipped_autoanalyze_time) + +/* pg_stat_get_last_skipped_autovacuum_time */ +PG_STAT_GET_RELENTRY_TIMESTAMPTZ(last_skipped_autovacuum_time) + +/* pg_stat_get_last_skipped_vacuum_time */ +PG_STAT_GET_RELENTRY_TIMESTAMPTZ(last_skipped_vacuum_time) + /* pg_stat_get_stat_reset_time */ PG_STAT_GET_RELENTRY_TIMESTAMPTZ(stat_reset_time) diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 0118e970dda..ccfe3d9e02a 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -5667,6 +5667,38 @@ proname => 'pg_stat_get_total_autoanalyze_time', provolatile => 's', proparallel => 'r', prorettype => 'float8', proargtypes => 'oid', prosrc => 'pg_stat_get_total_autoanalyze_time' }, +{ oid => '8142', descr => 'statistics: last skipped vacuum time for a table', + proname => 'pg_stat_get_last_skipped_vacuum_time', provolatile => 's', + proparallel => 'r', prorettype => 'timestamptz', proargtypes => 'oid', + prosrc => 'pg_stat_get_last_skipped_vacuum_time' }, +{ oid => '8143', descr => 'statistics: last skipped auto vacuum time for a table', + proname => 'pg_stat_get_last_skipped_autovacuum_time', provolatile => 's', + proparallel => 'r', prorettype => 'timestamptz', proargtypes => 'oid', + prosrc => 'pg_stat_get_last_skipped_autovacuum_time' }, +{ oid => '8144', descr => 'statistics: last skipped analyze time for a table', + proname => 'pg_stat_get_last_skipped_analyze_time', provolatile => 's', + proparallel => 'r', prorettype => 'timestamptz', proargtypes => 'oid', + prosrc => 'pg_stat_get_last_skipped_analyze_time' }, +{ oid => '8145', descr => 'statistics: last skipped auto analyze time for a table', + proname => 'pg_stat_get_last_skipped_autoanalyze_time', provolatile => 's', + proparallel => 'r', prorettype => 'timestamptz', proargtypes => 'oid', + prosrc => 'pg_stat_get_last_skipped_autoanalyze_time' }, +{ oid => '8146', descr => 'statistics: number of skipped vacuum for a table', + proname => 'pg_stat_get_skipped_vacuum_count', provolatile => 's', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_skipped_vacuum_count' }, +{ oid => '8147', descr => 'statistics: number of skipped auto vacuum for a table', + proname => 'pg_stat_get_skipped_autovacuum_count', provolatile => 's', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_skipped_autovacuum_count' }, +{ oid => '8148', descr => 'statistics: number of skipped analyzes for a table', + proname => 'pg_stat_get_skipped_analyze_count', provolatile => 's', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_skipped_analyze_count' }, +{ oid => '8149', descr => 'statistics: number of skipped auto analyzes for a table', + proname => 'pg_stat_get_skipped_autoanalyze_count', provolatile => 's', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_skipped_autoanalyze_count' }, { oid => '1936', descr => 'statistics: currently active backend IDs', proname => 'pg_stat_get_backend_idset', prorows => '100', proretset => 't', provolatile => 's', proparallel => 'r', prorettype => 'int4', diff --git a/src/include/pgstat.h b/src/include/pgstat.h index 8e3549c3752..d7f19d91783 100644 --- a/src/include/pgstat.h +++ b/src/include/pgstat.h @@ -479,6 +479,15 @@ typedef struct PgStat_StatTabEntry TimestampTz last_autoanalyze_time; /* autovacuum initiated */ PgStat_Counter autoanalyze_count; + TimestampTz last_skipped_vacuum_time; /* user initiated vacuum */ + PgStat_Counter skipped_vacuum_count; + TimestampTz last_skipped_autovacuum_time; /* autovacuum initiated */ + PgStat_Counter skipped_autovacuum_count; + TimestampTz last_skipped_analyze_time; /* user initiated */ + PgStat_Counter skipped_analyze_count; + TimestampTz last_skipped_autoanalyze_time; /* autovacuum initiated */ + PgStat_Counter skipped_autoanalyze_count; + PgStat_Counter total_vacuum_time; /* times in milliseconds */ PgStat_Counter total_autovacuum_time; PgStat_Counter total_analyze_time; @@ -706,6 +715,8 @@ extern void pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples, extern void pgstat_report_analyze(Relation rel, PgStat_Counter livetuples, PgStat_Counter deadtuples, bool resetcounter, TimestampTz starttime); +extern void pgstat_report_skipped_vacuum_analyze(Oid relid, bool vacuum, bool analyze, + bool autovacuum); /* * If stats are enabled, but pending data hasn't been prepared yet, call diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 2b3cf6d8569..4ccfc43e903 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -1835,13 +1835,21 @@ pg_stat_all_tables| SELECT c.oid AS relid, pg_stat_get_mod_since_analyze(c.oid) AS n_mod_since_analyze, pg_stat_get_ins_since_vacuum(c.oid) AS n_ins_since_vacuum, pg_stat_get_last_vacuum_time(c.oid) AS last_vacuum, + pg_stat_get_last_skipped_vacuum_time(c.oid) AS last_skipped_vacuum, pg_stat_get_last_autovacuum_time(c.oid) AS last_autovacuum, + pg_stat_get_last_skipped_autovacuum_time(c.oid) AS last_skipped_autovacuum, pg_stat_get_last_analyze_time(c.oid) AS last_analyze, + pg_stat_get_last_skipped_analyze_time(c.oid) AS last_skipped_analyze, pg_stat_get_last_autoanalyze_time(c.oid) AS last_autoanalyze, + pg_stat_get_last_skipped_autoanalyze_time(c.oid) AS last_skipped_autoanalyze, pg_stat_get_vacuum_count(c.oid) AS vacuum_count, + pg_stat_get_skipped_vacuum_count(c.oid) AS skipped_vacuum_count, pg_stat_get_autovacuum_count(c.oid) AS autovacuum_count, + pg_stat_get_skipped_autovacuum_count(c.oid) AS skipped_autovacuum_count, pg_stat_get_analyze_count(c.oid) AS analyze_count, + pg_stat_get_skipped_analyze_count(c.oid) AS skipped_analyze_count, pg_stat_get_autoanalyze_count(c.oid) AS autoanalyze_count, + pg_stat_get_skipped_autoanalyze_count(c.oid) AS skipped_autoanalyze_count, pg_stat_get_total_vacuum_time(c.oid) AS total_vacuum_time, pg_stat_get_total_autovacuum_time(c.oid) AS total_autovacuum_time, pg_stat_get_total_analyze_time(c.oid) AS total_analyze_time, @@ -2293,13 +2301,21 @@ pg_stat_sys_tables| SELECT relid, n_mod_since_analyze, n_ins_since_vacuum, last_vacuum, + last_skipped_vacuum, last_autovacuum, + last_skipped_autovacuum, last_analyze, + last_skipped_analyze, last_autoanalyze, + last_skipped_autoanalyze, vacuum_count, + skipped_vacuum_count, autovacuum_count, + skipped_autovacuum_count, analyze_count, + skipped_analyze_count, autoanalyze_count, + skipped_autoanalyze_count, total_vacuum_time, total_autovacuum_time, total_analyze_time, @@ -2348,13 +2364,21 @@ pg_stat_user_tables| SELECT relid, n_mod_since_analyze, n_ins_since_vacuum, last_vacuum, + last_skipped_vacuum, last_autovacuum, + last_skipped_autovacuum, last_analyze, + last_skipped_analyze, last_autoanalyze, + last_skipped_autoanalyze, vacuum_count, + skipped_vacuum_count, autovacuum_count, + skipped_autovacuum_count, analyze_count, + skipped_analyze_count, autoanalyze_count, + skipped_autoanalyze_count, total_vacuum_time, total_autovacuum_time, total_analyze_time, -- 2.43.0 --Multipart=_Thu__26_Mar_2026_19_22_03_+0900_+1+LQ8ruMpR+Xuy_-- ^ permalink raw reply [nested|flat] 814+ messages in thread
end of thread, other threads:[~2026-03-24 04:09 UTC | newest] Thread overview: 814+ messages (download: mbox mbox.gz follow: Atom feed) -- links below jump to the message on this page -- 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2025-07-18 14:52 [PATCH v3 2/2] Error out if one iteration of non-direct DML affects more than one row on the foreign server Jehan-Guillaume de Rorthais <[email protected]> 2026-03-24 04:09 [PATCH v2] Track skipped tables during vacuum and analyze Yugo Nagata <[email protected]>
This inbox is served by agora; see mirroring instructions for how to clone and mirror all data and code used for this inbox